feat: add missing folder + assets management operations + various fixes

scarlett
NGPixel 6 days ago
parent bfafd9d434
commit 7dfb9abe32
No known key found for this signature in database

@ -27,15 +27,22 @@ const assetIdParam = {
* Assets live in the same tree as pages and are addressed by the same rules a rule over a branch * Assets live in the same tree as pages and are addressed by the same rules a rule over a branch
* covers the files in it as well as the pages, which is why the asset permissions are offered * covers the files in it as well as the pages, which is why the asset permissions are offered
* alongside the page ones in the group editor. * alongside the page ones in the group editor.
*
* Where it sits includes the LOCALE it is in, and the parameter is required for that reason: a rule
* may be limited to particular locales, and `ruleMatchesPage` treats a reference with no locale as
* one no locale restriction applies to so an omitted locale silently widens every such rule. Every
* asset the API hands around carries one, and the two places that build a destination by hand say
* which locale they mean.
*/ */
function mayOnAsset( function mayOnAsset(
req: FastifyRequest, req: FastifyRequest,
permission: string, permission: string,
asset: { folderPath?: string | null; fileName: string } asset: { folderPath?: string | null; fileName: string; locale: string }
): boolean { ): boolean {
const folder = asset.folderPath ?? '' const folder = asset.folderPath ?? ''
return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, { return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, {
path: folder ? `${folder}/${asset.fileName}` : asset.fileName path: folder ? `${folder}/${asset.fileName}` : asset.fileName,
locale: asset.locale
}) })
} }
@ -151,14 +158,21 @@ async function routes(app: FastifyInstance) {
const destination = folder const destination = folder
? [parentPath, folder.fileName].filter(Boolean).join('/') ? [parentPath, folder.fileName].filter(Boolean).join('/')
: (folderPath ?? '') : (folderPath ?? '')
// -> Settled once, since the rule check and the write have to be asking about the same locale
const locale =
req.query.locale ?? WIKI.sites[req.params.siteId]?.config?.locales?.primary ?? 'en'
if ( if (
!mayOnAsset(req, 'write:assets', { folderPath: destination, fileName: req.query.fileName }) !mayOnAsset(req, 'write:assets', {
folderPath: destination,
fileName: req.query.fileName,
locale
})
) { ) {
return reply.forbidden('You are not allowed to upload a file here.') return reply.forbidden('You are not allowed to upload a file here.')
} }
const asset = await WIKI.models.assets.upload({ const asset = await WIKI.models.assets.upload({
siteId: req.params.siteId, siteId: req.params.siteId,
locale: req.query.locale ?? WIKI.sites[req.params.siteId]?.config?.locales?.primary ?? 'en', locale,
folderId: req.query.folderId, folderId: req.query.folderId,
folderPath, folderPath,
fileName: req.query.fileName, fileName: req.query.fileName,
@ -279,9 +293,12 @@ async function routes(app: FastifyInstance) {
) )
/** /**
* RENAME ASSET * RENAME / MOVE ASSET
*/ */
app.patch<{ Params: { siteId: string; assetId: string }; Body: { fileName: string } }>( app.patch<{
Params: { siteId: string; assetId: string }
Body: { fileName?: string; folderPath?: string; locale?: string }
}>(
'/sites/:siteId/assets/:assetId', '/sites/:siteId/assets/:assetId',
{ {
/* /*
@ -289,26 +306,37 @@ async function routes(app: FastifyInstance) {
from a group's RULES, which address the folder the file is in. Checked below. from a group's RULES, which address the folder the file is in. Checked below.
*/ */
schema: { schema: {
summary: 'Rename an asset', summary: 'Rename an asset or move it to another folder',
description: description:
'The extension is part of the name, and changing it changes the type the file is served as.', 'Any of the three may be sent on its own, and they are the same operation to a storage target: a file is addressed by its locale, its folder and its name together, so the copy on every target follows. The extension is part of the name, and changing it changes the type the file is served as.\n\nMoving needs `manage:assets` at the destination as well as at the source, since page rules are granted per path and per locale.',
tags: ['Assets'], tags: ['Assets'],
params: assetIdParam, params: assetIdParam,
body: { body: {
type: 'object', type: 'object',
required: ['fileName'],
properties: { properties: {
fileName: { fileName: {
type: 'string', type: 'string',
minLength: 3, minLength: 3,
maxLength: 255, maxLength: 255,
description: 'Sanitized, so the stored name may differ from the one sent.' description:
'Sanitized, so the stored name may differ from the one sent. Keeps its current name when absent.'
},
folderPath: {
type: 'string',
maxLength: 255,
description:
'The folder to move it to, from the site root, empty for the root itself. Created if it does not exist. Stays where it is when absent.'
},
locale: {
type: 'string',
maxLength: 255,
description: 'The locale to move it to. Stays in its own when absent.'
} }
} }
}, },
response: { response: {
200: { 200: {
description: 'Asset renamed successfully', description: 'Asset renamed or moved successfully',
type: 'object', type: 'object',
properties: { properties: {
ok: { ok: {
@ -329,19 +357,38 @@ async function routes(app: FastifyInstance) {
return reply.notFound('This asset does not exist.') return reply.notFound('This asset does not exist.')
} }
if (!mayOnAsset(req, 'manage:assets', existing)) { if (!mayOnAsset(req, 'manage:assets', existing)) {
return reply.forbidden('You are not allowed to rename this file.') return reply.forbidden('You are not allowed to rename or move this file.')
}
/*
And at the destination, when that is somewhere else: rules are granted per path AND per
locale, so a move is a write to a place the mover may have no say over -- which without this
is a way to put a file where they could not have uploaded one.
*/
const destination = {
folderPath:
req.body.folderPath === undefined
? existing.folderPath
: normalizeFolderPath(req.body.folderPath),
fileName: req.body.fileName ?? existing.fileName,
locale: req.body.locale || existing.locale
}
const isRelocated =
destination.folderPath !== existing.folderPath || destination.locale !== existing.locale
if (isRelocated && !mayOnAsset(req, 'manage:assets', destination)) {
return reply.forbidden('You are not allowed to move this file there.')
} }
const asset = await WIKI.models.assets.renameAsset( const asset = await WIKI.models.assets.moveAsset(
req.params.siteId, req.params.siteId,
req.params.assetId, req.params.assetId,
req.body.fileName req.body,
req.session.user?.id
) )
if (!asset) { if (!asset) {
return reply.notFound('This asset does not exist.') return reply.notFound('This asset does not exist.')
} }
return { return {
ok: true, ok: true,
message: 'Asset renamed successfully.', message: isRelocated ? 'Asset moved successfully.' : 'Asset renamed successfully.',
asset asset
} }
} }

@ -41,6 +41,15 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'boolean', type: 'boolean',
description: 'Whether a thumbnail was generated, and `/_thumb/<id>.webp` will serve one.' description: 'Whether a thumbnail was generated, and `/_thumb/<id>.webp` will serve one.'
}, },
width: {
type: 'integer',
description:
'Images only — in pixels, as displayed. Absent when the dimensions could not be read, which is the case for anything uploaded while the Sharp extension was missing.'
},
height: {
type: 'integer',
description: 'Images only — in pixels, as displayed.'
},
createdAt: { createdAt: {
type: 'string', type: 'string',
format: 'date-time' format: 'date-time'

@ -48,6 +48,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'integer', type: 'integer',
description: 'Folders only — how many entries the folder holds.' description: 'Folders only — how many entries the folder holds.'
}, },
hue: {
type: 'integer',
description:
'Folders only — how far the folder icon is rotated around the colour wheel, in degrees. Absent on a folder left the colour every folder starts out.'
},
isAncestor: { isAncestor: {
type: 'boolean', type: 'boolean',
description: description:
@ -65,6 +70,15 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'string', type: 'string',
description: 'Assets only.' description: 'Assets only.'
}, },
width: {
type: 'integer',
description:
'Image assets only — in pixels, as displayed. Absent when the dimensions were never read.'
},
height: {
type: 'integer',
description: 'Image assets only — in pixels, as displayed.'
},
editor: { editor: {
type: 'string', type: 'string',
description: 'Pages only.' description: 'Pages only.'
@ -189,6 +203,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
childrenCount: { childrenCount: {
type: 'integer' type: 'integer'
}, },
hue: {
type: 'integer',
description:
'How far the folder icon is rotated around the colour wheel, in degrees. Absent on a folder left the colour every folder starts out.'
},
createdAt: { createdAt: {
type: 'string', type: 'string',
format: 'date-time' format: 'date-time'

@ -1,6 +1,6 @@
import type { FastifyInstance, FastifyRequest } from 'fastify' import type { FastifyInstance, FastifyRequest } from 'fastify'
import { TREE_ORDER_BY, type TreeItemType, type TreeOrderBy } from '../models/tree.ts' import { TREE_ORDER_BY, type TreeItemType, type TreeOrderBy, type TreeRow } from '../models/tree.ts'
import { decodeTreePath } from '../helpers/common.ts' import { decodeTreePath, normalizeFolderPath } from '../helpers/common.ts'
import { actorFrom } from './pages.ts' import { actorFrom } from './pages.ts'
interface TreeQuery { interface TreeQuery {
@ -106,6 +106,24 @@ function visibleTreeItems<T extends { type?: string; folderPath?: string; fileNa
} }
/** A folder's own slash-separated path, which is what a rule over that branch addresses. */ /** A folder's own slash-separated path, which is what a rule over that branch addresses. */
/**
* A folder as the `Folder` schema describes one.
*
* Five routes answer with a folder and each of them has to unpack the same two things out of the
* row -- the ltree path as a readable one, and the parts of `meta` that are a folder's own rather
* than the model's bookkeeping. Kept in one place so that adding a third cannot reach four routes
* and miss the fifth.
*/
function toFolderResponse(folder: TreeRow) {
return {
...folder,
folderPath: decodeTreePath(folder.folderPath ?? '') ?? '',
childrenCount: folder.meta?.children ?? 0,
// -> Absent rather than zero on a folder nobody has coloured; see `setFolderColor`
...(folder.meta?.hue ? { hue: folder.meta.hue } : {})
}
}
function folderPathOf(folder: { folderPath?: string | null; fileName: string }): string { function folderPathOf(folder: { folderPath?: string | null; fileName: string }): string {
const parent = decodeTreePath(folder.folderPath ?? '') ?? '' const parent = decodeTreePath(folder.folderPath ?? '') ?? ''
return parent ? `${parent}/${folder.fileName}` : folder.fileName return parent ? `${parent}/${folder.fileName}` : folder.fileName
@ -118,9 +136,15 @@ function folderPathOf(folder: { folderPath?: string | null; fileName: string }):
* branch it opens: a rule denying `read:pages` under `geography` hides the folder as well as the * branch it opens: a rule denying `read:pages` under `geography` hides the folder as well as the
* pages in it, and only somebody who may reorganise pages there may rename or remove it. * pages in it, and only somebody who may reorganise pages there may rename or remove it.
*/ */
function mayOnFolder(req: FastifyRequest, permission: string, path: string): boolean { function mayOnFolder(
req: FastifyRequest,
permission: string,
path: string,
locale: string
): boolean {
return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, { return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, {
path path,
locale
}) })
} }
@ -447,14 +471,10 @@ async function routes(app: FastifyInstance) {
} }
const folderPath = folderPathOf(folder) const folderPath = folderPathOf(folder)
// -> Not visible is the same as not there, so it answers as the id had matched nothing // -> Not visible is the same as not there, so it answers as the id had matched nothing
if (!mayOnFolder(req, 'read:pages', folderPath)) { if (!mayOnFolder(req, 'read:pages', folderPath, folder.locale)) {
return reply.notFound('This folder does not exist.') return reply.notFound('This folder does not exist.')
} }
return { return toFolderResponse(folder)
...folder,
folderPath: decodeTreePath(folder.folderPath ?? '') ?? '',
childrenCount: folder.meta?.children ?? 0
}
} }
) )
@ -528,12 +548,13 @@ async function routes(app: FastifyInstance) {
parentPath = parent ? folderPathOf(parent) : parentPath parentPath = parent ? folderPathOf(parent) : parentPath
} }
const target = [parentPath, req.body.pathName].filter(Boolean).join('/') const target = [parentPath, req.body.pathName].filter(Boolean).join('/')
if (!mayOnFolder(req, 'manage:pages', target)) { const locale = req.body.locale ?? defaultLocale(req.params.siteId)
if (!mayOnFolder(req, 'manage:pages', target, locale)) {
return reply.forbidden('You are not allowed to create a folder here.') return reply.forbidden('You are not allowed to create a folder here.')
} }
const folder = await WIKI.models.tree.createFolder({ const folder = await WIKI.models.tree.createFolder({
siteId: req.params.siteId, siteId: req.params.siteId,
locale: req.body.locale ?? defaultLocale(req.params.siteId), locale,
parentId: req.body.parentId, parentId: req.body.parentId,
parentPath: req.body.parentPath, parentPath: req.body.parentPath,
pathName: req.body.pathName, pathName: req.body.pathName,
@ -542,11 +563,7 @@ async function routes(app: FastifyInstance) {
return { return {
ok: true, ok: true,
message: 'Folder created successfully.', message: 'Folder created successfully.',
folder: { folder: toFolderResponse(folder)
...folder,
folderPath: decodeTreePath(folder.folderPath ?? '') ?? '',
childrenCount: folder.meta?.children ?? 0
}
} }
} }
) )
@ -592,7 +609,7 @@ async function routes(app: FastifyInstance) {
if (!existing || existing.siteId !== req.params.siteId) { if (!existing || existing.siteId !== req.params.siteId) {
return reply.notFound('This folder does not exist.') return reply.notFound('This folder does not exist.')
} }
if (!mayOnFolder(req, 'manage:pages', folderPathOf(existing))) { if (!mayOnFolder(req, 'manage:pages', folderPathOf(existing), existing.locale)) {
return reply.forbidden('You are not allowed to rename this folder.') return reply.forbidden('You are not allowed to rename this folder.')
} }
const folder = await WIKI.models.tree.renameFolder({ const folder = await WIKI.models.tree.renameFolder({
@ -604,12 +621,268 @@ async function routes(app: FastifyInstance) {
return { return {
ok: true, ok: true,
message: 'Folder renamed successfully.', message: 'Folder renamed successfully.',
folder: { folder: toFolderResponse(folder)
...folder, }
folderPath: decodeTreePath(folder.folderPath ?? '') ?? '', }
childrenCount: folder.meta?.children ?? 0 )
/**
* MOVE FOLDER
*/
app.put<{
Params: { siteId: string; folderId: string }
Body: { folderPath: string; locale?: string }
}>(
'/sites/:siteId/tree/folders/:folderId/path',
{
/*
No route-level `permissions`: that hook reads the group-wide list, and page permissions come
from a group's RULES. Checked against the folder's own path below.
*/
schema: {
summary: 'Move a folder to another parent',
description:
'Everything under the folder moves with it — pages, files, and the folders in between — and every storage target holding a copy follows. Any folder the destination needs is created.\n\nMoving needs `manage:pages` at the destination as well as at the source, since page rules are granted per path and per locale. A folder cannot be moved into its own subtree.',
tags: ['Tree'],
params: folderIdParam,
body: {
type: 'object',
required: ['folderPath'],
properties: {
folderPath: {
type: 'string',
maxLength: 255,
description:
'The folder to move it into, from the site root, empty for the root itself. Created if it does not exist.'
},
locale: {
type: 'string',
maxLength: 255,
description: 'The locale to move it to. Stays in its own when absent.'
}
}
},
response: {
200: {
description: 'Folder moved successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
folder: { $ref: 'Folder#' }
}
}
}
}
},
async (req, reply) => {
const existing = await WIKI.models.tree.getFolderById(req.params.folderId)
if (!existing || existing.siteId !== req.params.siteId) {
return reply.notFound('This folder does not exist.')
}
if (!mayOnFolder(req, 'manage:pages', folderPathOf(existing), existing.locale)) {
return reply.forbidden('You are not allowed to move this folder.')
}
/*
And where it is going, which is a whole branch arriving somewhere the mover may have no say
over: rules are granted per path AND per locale, so without this a folder full of pages is a
way to write into a place they could not have created one.
*/
const destination = normalizeFolderPath(req.body.folderPath)
const destinationLocale = req.body.locale || existing.locale
if (
!mayOnFolder(
req,
'manage:pages',
[destination, existing.fileName].filter(Boolean).join('/'),
destinationLocale
)
) {
return reply.forbidden('You are not allowed to move this folder there.')
}
const folder = await WIKI.models.tree.moveFolder({
folderId: req.params.folderId,
folderPath: destination,
locale: destinationLocale,
actorId: req.session.user?.id
})
return {
ok: true,
message: 'Folder moved successfully.',
folder: toFolderResponse(folder)
}
}
)
/**
* DUPLICATE FOLDER
*/
app.post<{
Params: { siteId: string; folderId: string }
Body: { folderPath: string; pathName: string; title: string; locale?: string }
}>(
'/sites/:siteId/tree/folders/:folderId/duplicate',
{
/*
No route-level `permissions`: that hook reads the group-wide list, and page permissions come
from a group's RULES. Checked against the folder's own path below.
*/
schema: {
summary: 'Duplicate a folder',
description:
'Copies the folder and everything under it — the folders in between, the pages and the files, each as a new entry of its own with its own copy on every storage target. Aliases, translation sets and sidebar overrides are not carried across; folder colours are.\n\nCopying needs `manage:pages` at the destination as well as at the source, since page rules are granted per path and per locale. A folder cannot be copied into its own subtree, and the name must be free where it is going.',
tags: ['Tree'],
params: folderIdParam,
body: {
allOf: [
{ $ref: 'FolderInput#' },
{ type: 'object', required: ['pathName', 'title'] },
{
type: 'object',
properties: {
folderPath: {
type: 'string',
maxLength: 2048,
description:
'Slash-separated path of the folder to copy into, empty for the site root. Created if it does not exist.'
},
locale: {
type: 'string',
maxLength: 10,
description: "The source folder's own locale when absent."
}
}
}
]
},
response: {
200: {
description: 'Folder duplicated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
folder: { $ref: 'Folder#' }
}
}
} }
} }
},
async (req, reply) => {
const actor = actorFrom(req)
if (!actor) {
return reply.unauthorized('Duplicating a folder requires a logged in user.')
}
const existing = await WIKI.models.tree.getFolderById(req.params.folderId)
if (!existing || existing.siteId !== req.params.siteId) {
return reply.notFound('This folder does not exist.')
}
if (!mayOnFolder(req, 'manage:pages', folderPathOf(existing), existing.locale)) {
return reply.forbidden('You are not allowed to duplicate this folder.')
}
// -> And where the copy is going, which is a whole branch of new pages arriving somewhere the
// copier may have no say over
const destination = normalizeFolderPath(req.body.folderPath)
const destinationLocale = req.body.locale || existing.locale
if (
!mayOnFolder(
req,
'manage:pages',
[destination, req.body.pathName].filter(Boolean).join('/'),
destinationLocale
)
) {
return reply.forbidden('You are not allowed to duplicate this folder there.')
}
const folder = await WIKI.models.tree.duplicateFolder({
folderId: req.params.folderId,
folderPath: destination,
pathName: req.body.pathName,
title: req.body.title,
locale: destinationLocale,
actor
})
return {
ok: true,
message: 'Folder duplicated successfully.',
folder: toFolderResponse(folder)
}
}
)
/**
* SET FOLDER COLOR
*/
app.put<{
Params: { siteId: string; folderId: string }
Body: { hue: number }
}>(
'/sites/:siteId/tree/folders/:folderId/color',
{
/*
No route-level `permissions`: that hook reads the group-wide list, and page permissions come
from a group's RULES. Checked against the folder's own path below.
*/
schema: {
summary: "Set a folder's colour",
description:
'A hue rotation in degrees applied to the folder icon, rather than a colour: the icon is one image that the interface turns around the colour wheel. Zero is the colour every folder starts out, and clears the setting.',
tags: ['Tree'],
params: folderIdParam,
body: {
type: 'object',
required: ['hue'],
properties: {
hue: {
type: 'integer',
minimum: 0,
maximum: 359,
description: 'Degrees around the colour wheel. Zero puts the folder back to yellow.'
}
}
},
response: {
200: {
description: 'Folder colour set successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
folder: { $ref: 'Folder#' }
}
}
}
}
},
async (req, reply) => {
const existing = await WIKI.models.tree.getFolderById(req.params.folderId)
if (!existing || existing.siteId !== req.params.siteId) {
return reply.notFound('This folder does not exist.')
}
if (!mayOnFolder(req, 'manage:pages', folderPathOf(existing), existing.locale)) {
return reply.forbidden('You are not allowed to change this folder.')
}
const folder = await WIKI.models.tree.setFolderColor({
folderId: req.params.folderId,
hue: req.body.hue
})
return {
ok: true,
message: 'Folder colour set successfully.',
folder: toFolderResponse(folder)
}
} }
) )
@ -647,7 +920,7 @@ async function routes(app: FastifyInstance) {
if (!existing || existing.siteId !== req.params.siteId) { if (!existing || existing.siteId !== req.params.siteId) {
return reply.notFound('This folder does not exist.') return reply.notFound('This folder does not exist.')
} }
if (!mayOnFolder(req, 'manage:pages', folderPathOf(existing))) { if (!mayOnFolder(req, 'manage:pages', folderPathOf(existing), existing.locale)) {
return reply.forbidden('You are not allowed to delete this folder.') return reply.forbidden('You are not allowed to delete this folder.')
} }
const removed = await WIKI.models.tree.deleteFolder(req.params.folderId) const removed = await WIKI.models.tree.deleteFolder(req.params.folderId)

@ -193,3 +193,51 @@ export async function makeImageThumbnail(
return null return null
} }
} }
/** How big an image is, in pixels, as it is meant to be displayed. */
export type ImageDimensions = {
width: number
height: number
}
/**
* Read an image's pixel dimensions, using the Sharp extension.
*
* Recorded once, when the file arrives, so that showing them later costs nothing the file manager
* lists a folder at a time and must not read every image to describe one.
*
* The dimensions are the ones a viewer will show, not the ones the pixels are stored in: an EXIF
* orientation of 5 or above means the decoder turns the image a quarter turn, and Sharp reports the
* pre-rotation size, so the two are swapped back here. A portrait photo off a phone is exactly this
* case, and reporting it as landscape is worse than reporting nothing.
*
* @returns The dimensions, or null if Sharp is not usable on this system or these bytes are not an
* image it can read
*/
export async function readImageDimensions(data: Buffer): Promise<ImageDimensions | null> {
const definition = WIKI.models.extensions.getDefinition('sharp')
if (!definition || !(await WIKI.models.extensions.isInstalled(definition))) {
return null
}
const specifier = 'sharp'
// -> Loading Sharp and running it are kept apart, as everywhere else here: whatever a user uploaded
// may simply not be an image Sharp can read, which must not be recorded as Sharp being broken
let sharp: any
try {
;({ default: sharp } = await import(specifier))
} catch (err: any) {
WIKI.models.extensions.noteLoadFailure(specifier)
WIKI.logger.warn(`Could not load Sharp to measure an image: ${err.message}`)
return null
}
try {
const { width, height, orientation } = await sharp(data).metadata()
if (!width || !height) {
return null
}
return orientation && orientation >= 5 ? { width: height, height: width } : { width, height }
} catch (err: any) {
WIKI.logger.debug(`Could not read the dimensions of an upload: ${err.message}`)
return null
}
}

@ -1443,6 +1443,7 @@
"common.actions.saveAndClose": "Save and Close", "common.actions.saveAndClose": "Save and Close",
"common.actions.saveChanges": "Save Changes", "common.actions.saveChanges": "Save Changes",
"common.actions.select": "Select", "common.actions.select": "Select",
"common.actions.setColor": "Set Color",
"common.actions.submitEdits": "Submit Edits", "common.actions.submitEdits": "Submit Edits",
"common.actions.suggestEdits": "Suggest Edits", "common.actions.suggestEdits": "Suggest Edits",
"common.actions.suggestedEdit": "Suggested Edit", "common.actions.suggestedEdit": "Suggested Edit",
@ -2001,7 +2002,8 @@
"fileman.assetDeleteSuccess": "Asset deleted successfully.", "fileman.assetDeleteSuccess": "Asset deleted successfully.",
"fileman.assetFileName": "Asset Name", "fileman.assetFileName": "Asset Name",
"fileman.assetFileNameHint": "Filename of the asset, including the file extension.", "fileman.assetFileNameHint": "Filename of the asset, including the file extension.",
"fileman.assetRename": "Rename Asset", "fileman.assetLocaleHint": "Which locale the file belongs to.",
"fileman.assetRenameMove": "Rename / Move Asset",
"fileman.aviFileType": "AVI Video File", "fileman.aviFileType": "AVI Video File",
"fileman.binFileType": "Binary File", "fileman.binFileType": "Binary File",
"fileman.bz2FileType": "BZIP2 Archive", "fileman.bz2FileType": "BZIP2 Archive",
@ -2011,6 +2013,7 @@
"fileman.cssFileType": "Cascade Style Sheet", "fileman.cssFileType": "Cascade Style Sheet",
"fileman.csvFileType": "Comma Separated Values Document", "fileman.csvFileType": "Comma Separated Values Document",
"fileman.dataFileType": "Data File", "fileman.dataFileType": "Data File",
"fileman.detailsAssetDimensions": "Dimensions",
"fileman.detailsAssetSize": "File Size", "fileman.detailsAssetSize": "File Size",
"fileman.detailsAssetType": "Type", "fileman.detailsAssetType": "Type",
"fileman.detailsPageCreated": "Created", "fileman.detailsPageCreated": "Created",
@ -2024,11 +2027,18 @@
"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",
"fileman.folderColor": "Set Folder Color",
"fileman.folderCreate": "New Folder", "fileman.folderCreate": "New Folder",
"fileman.folderDuplicate": "Duplicate Folder To...",
"fileman.folderDuplicating": "Duplicating folder...",
"fileman.folderDuplicatingHint": "Every page and file under it is being copied. This may take a moment for a large folder.",
"fileman.folderFileName": "Path Name", "fileman.folderFileName": "Path Name",
"fileman.folderFileNameHint": "URL friendly version of the folder name. Must consist of lowercase alphanumerical or hypen characters only.", "fileman.folderFileNameHint": "URL friendly version of the folder name. Must consist of lowercase alphanumerical or hypen characters only.",
"fileman.folderFileNameInvalid": "Invalid Characters in Folder Path Name. Lowercase alphanumerical and hyphen characters only.", "fileman.folderFileNameInvalid": "Invalid Characters in Folder Path Name. Lowercase alphanumerical and hyphen characters only.",
"fileman.folderFileNameMissing": "Missing Folder Path Name", "fileman.folderFileNameMissing": "Missing Folder Path Name",
"fileman.folderMove": "Move Folder To...",
"fileman.folderMoving": "Moving folder...",
"fileman.folderMovingHint": "Every page and file under it is moving with it. This may take a moment for a large folder.",
"fileman.folderRename": "Rename Folder", "fileman.folderRename": "Rename Folder",
"fileman.folderTitle": "Title", "fileman.folderTitle": "Title",
"fileman.folderTitleInvalidChars": "Invalid Characters in Folder Name", "fileman.folderTitleInvalidChars": "Invalid Characters in Folder Name",
@ -2053,13 +2063,15 @@
"fileman.oggFileType": "OGG Audio File", "fileman.oggFileType": "OGG Audio File",
"fileman.otfFileType": "OpenType Font File", "fileman.otfFileType": "OpenType Font File",
"fileman.pdfFileType": "PDF Document", "fileman.pdfFileType": "PDF Document",
"fileman.previewActualSize": "Actual Size",
"fileman.previewFailed": "This image could not be loaded.",
"fileman.previewFitToScreen": "Fit to Screen",
"fileman.pngFileType": "PNG Image", "fileman.pngFileType": "PNG Image",
"fileman.pptxFileType": "Microsoft Powerpoint Presentation", "fileman.pptxFileType": "Microsoft Powerpoint Presentation",
"fileman.psdFileType": "Adobe Photoshop Document", "fileman.psdFileType": "Adobe Photoshop Document",
"fileman.rarFileType": "RAR Archive", "fileman.rarFileType": "RAR Archive",
"fileman.redirectPageType": "Redirection", "fileman.redirectPageType": "Redirection",
"fileman.renameAssetInvalid": "Asset name is invalid.", "fileman.renameAssetInvalid": "Asset name is invalid.",
"fileman.renameAssetSuccess": "Asset renamed successfully",
"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.searchFolder": "Search folder...", "fileman.searchFolder": "Search folder...",

@ -3,8 +3,14 @@ import path from 'node:path'
import mime from 'mime' import mime from 'mime'
import { and, desc, eq, inArray, isNotNull, sql } from 'drizzle-orm' import { and, desc, eq, inArray, isNotNull, sql } from 'drizzle-orm'
import { assets as assetsTable, tree as treeTable } from '../db/schema.ts' import { assets as assetsTable, tree as treeTable } from '../db/schema.ts'
import { CustomError, decodeTreePath, encodeTreePath } from '../helpers/common.ts' import {
import { makeImageThumbnail } from '../helpers/images.ts' CustomError,
decodeTreePath,
encodeTreePath,
normalizeFolderPath
} from '../helpers/common.ts'
import { makeImageThumbnail, readImageDimensions } from '../helpers/images.ts'
import type { ImageDimensions } from '../helpers/images.ts'
import type { Readable } from 'node:stream' import type { Readable } from 'node:stream'
import type { DeletedEntry } from './tree.ts' import type { DeletedEntry } from './tree.ts'
import type { StorageAssetRef } from './storage.ts' import type { StorageAssetRef } from './storage.ts'
@ -101,6 +107,13 @@ export interface Asset {
locale: string locale: string
title: string title: string
hasPreview: boolean hasPreview: boolean
/**
* How big the image is, in pixels. Absent for anything that is not an image, and for an image whose
* dimensions could not be read when it arrived Sharp does the reading, and it is an optional
* dependency, so a file uploaded while it was missing has none and never will.
*/
width?: number
height?: number
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
} }
@ -149,6 +162,26 @@ function extensionOf(fileName: string): string {
return path.extname(fileName).replace(/^\./, '').toLowerCase() return path.extname(fileName).replace(/^\./, '').toLowerCase()
} }
/**
* What the dimensions contribute to a stored `meta` object, on the asset row and on the tree row
* alike.
*
* Spread rather than assigned, so that a file with no dimensions to record carries no keys for them
* rather than a pair of nulls: an absent key is the honest shape for "never measured", and it is what
* keeps a listing from claiming a document is zero pixels across.
*/
function dimensionMeta(dimensions: ImageDimensions | null): Record<string, number> {
return dimensions ? { width: dimensions.width, height: dimensions.height } : {}
}
/** The pair back out of anything carrying it, or null when only one of the two is there to read. */
function dimensionsOf(source: {
width?: number | null
height?: number | null
}): ImageDimensions | null {
return source.width && source.height ? { width: source.width, height: source.height } : null
}
function kindOf(mimeType: string, fileExt: string): AssetKind { function kindOf(mimeType: string, fileExt: string): AssetKind {
if (mimeType.startsWith('image/')) { if (mimeType.startsWith('image/')) {
return 'image' return 'image'
@ -335,6 +368,9 @@ class Assets {
kind === 'image' kind === 'image'
? await makeImageThumbnail(data, THUMBNAIL_SIZE.width, THUMBNAIL_SIZE.height) ? await makeImageThumbnail(data, THUMBNAIL_SIZE.width, THUMBNAIL_SIZE.height)
: null : null
// -> Read now, while the bytes are in hand: a folder listing describes a screenful of files at a
// time, and could not open each of them to say how big its image is
const dimensions = kind === 'image' ? await readImageDimensions(data) : null
// -> What is already at this name, if anything, and what the site says to do about it. Asked // -> What is already at this name, if anything, and what the site says to do about it. Asked
// before any row is touched, since two of the three answers write nothing new at all. // before any row is touched, since two of the three answers write nothing new at all.
@ -378,6 +414,7 @@ class Assets {
mimeType: resolvedMime, mimeType: resolvedMime,
data, data,
preview, preview,
dimensions,
authorId authorId
}) })
} }
@ -395,7 +432,8 @@ class Assets {
meta: { meta: {
fileSize: data.length, fileSize: data.length,
fileExt, fileExt,
mimeType: resolvedMime mimeType: resolvedMime,
...dimensionMeta(dimensions)
} }
}) })
const storedName = entry.fileName const storedName = entry.fileName
@ -412,6 +450,7 @@ class Assets {
kind, kind,
mimeType: resolvedMime, mimeType: resolvedMime,
fileSize: data.length, fileSize: data.length,
meta: dimensionMeta(dimensions),
preview, preview,
authorId, authorId,
siteId siteId
@ -456,6 +495,7 @@ class Assets {
locale, locale,
title: entry.title, title: entry.title,
hasPreview: Boolean(preview), hasPreview: Boolean(preview),
...dimensionMeta(dimensions),
createdAt: entry.createdAt, createdAt: entry.createdAt,
updatedAt: entry.updatedAt updatedAt: entry.updatedAt
} }
@ -489,6 +529,7 @@ class Assets {
mimeType, mimeType,
data, data,
preview, preview,
dimensions,
authorId authorId
}: { }: {
id: string id: string
@ -502,6 +543,7 @@ class Assets {
mimeType: string mimeType: string
data: Buffer data: Buffer
preview: Buffer | null preview: Buffer | null
dimensions: ImageDimensions | null
authorId: string authorId: string
}): Promise<Asset> { }): Promise<Asset> {
await WIKI.models.storage.putAsset( await WIKI.models.storage.putAsset(
@ -515,6 +557,10 @@ class Assets {
kind, kind,
mimeType, mimeType,
fileSize: data.length, fileSize: data.length,
// -> Set outright rather than merged, since these describe the bytes that just arrived: a
// replacement of another size overwrites the old measurements, and one that could not be
// measured at all leaves none behind
meta: dimensionMeta(dimensions),
preview, preview,
authorId, authorId,
updatedAt: sql`now()` updatedAt: sql`now()`
@ -523,7 +569,10 @@ class Assets {
// -> The tree carries its own copy of these, and it is what a folder listing reads // -> The tree carries its own copy of these, and it is what a folder listing reads
await WIKI.db await WIKI.db
.update(treeTable) .update(treeTable)
.set({ meta: { fileSize: data.length, fileExt, mimeType }, updatedAt: sql`now()` }) .set({
meta: { fileSize: data.length, fileExt, mimeType, ...dimensionMeta(dimensions) },
updatedAt: sql`now()`
})
.where(eq(treeTable.id, id)) .where(eq(treeTable.id, id))
// -> The path resolves to the same asset as before, but to different metadata: the ETag is the // -> The path resolves to the same asset as before, but to different metadata: the ETag is the
@ -557,6 +606,7 @@ class Assets {
locale, locale,
title, title,
hasPreview: Boolean(preview), hasPreview: Boolean(preview),
...dimensionMeta(dimensions),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date() updatedAt: new Date()
} }
@ -575,6 +625,7 @@ class Assets {
kind: assetsTable.kind, kind: assetsTable.kind,
mimeType: assetsTable.mimeType, mimeType: assetsTable.mimeType,
fileSize: assetsTable.fileSize, fileSize: assetsTable.fileSize,
meta: assetsTable.meta,
createdAt: assetsTable.createdAt, createdAt: assetsTable.createdAt,
updatedAt: assetsTable.updatedAt, updatedAt: assetsTable.updatedAt,
folderPath: treeTable.folderPath, folderPath: treeTable.folderPath,
@ -593,11 +644,15 @@ class Assets {
if (!row) { if (!row) {
return null return null
} }
// -> `meta` is where the row keeps the dimensions and is not itself part of an asset as the API
// describes one, so it is unpacked here rather than passed along
const { meta, ...rest } = row
return { return {
...row, ...rest,
fileSize: row.fileSize ?? 0, fileSize: row.fileSize ?? 0,
folderPath: decodeTreePath(row.folderPath ?? '') ?? '', folderPath: decodeTreePath(row.folderPath ?? '') ?? '',
hasPreview: Boolean(row.hasPreview) hasPreview: Boolean(row.hasPreview),
...dimensionMeta(dimensionsOf(meta as Record<string, any>))
} as Asset } as Asset
} }
@ -628,6 +683,7 @@ class Assets {
kind: assetsTable.kind, kind: assetsTable.kind,
mimeType: assetsTable.mimeType, mimeType: assetsTable.mimeType,
fileSize: assetsTable.fileSize, fileSize: assetsTable.fileSize,
meta: assetsTable.meta,
createdAt: assetsTable.createdAt, createdAt: assetsTable.createdAt,
updatedAt: assetsTable.updatedAt, updatedAt: assetsTable.updatedAt,
folderPath: treeTable.folderPath, folderPath: treeTable.folderPath,
@ -652,11 +708,15 @@ class Assets {
if (!row) { if (!row) {
return null return null
} }
// -> `meta` is where the row keeps the dimensions and is not itself part of an asset as the API
// describes one, so it is unpacked here rather than passed along
const { meta, ...rest } = row
return { return {
...row, ...rest,
fileSize: row.fileSize ?? 0, fileSize: row.fileSize ?? 0,
folderPath: decodeTreePath(row.folderPath ?? '') ?? '', folderPath: decodeTreePath(row.folderPath ?? '') ?? '',
hasPreview: Boolean(row.hasPreview) hasPreview: Boolean(row.hasPreview),
...dimensionMeta(dimensionsOf(meta as Record<string, any>))
} as AssetAtPath } as AssetAtPath
} }
@ -883,6 +943,7 @@ class Assets {
kind === 'image' kind === 'image'
? await makeImageThumbnail(data, THUMBNAIL_SIZE.width, THUMBNAIL_SIZE.height) ? await makeImageThumbnail(data, THUMBNAIL_SIZE.width, THUMBNAIL_SIZE.height)
: null : null
const dimensions = kind === 'image' ? await readImageDimensions(data) : null
if (occupant) { if (occupant) {
return this.replace({ return this.replace({
@ -899,6 +960,7 @@ class Assets {
mimeType, mimeType,
data, data,
preview, preview,
dimensions,
authorId authorId
}) })
} }
@ -909,7 +971,7 @@ class Assets {
title: safeName, title: safeName,
locale, locale,
siteId, siteId,
meta: { fileSize: data.length, fileExt, mimeType } meta: { fileSize: data.length, fileExt, mimeType, ...dimensionMeta(dimensions) }
}) })
try { try {
@ -920,6 +982,7 @@ class Assets {
kind, kind,
mimeType, mimeType,
fileSize: data.length, fileSize: data.length,
meta: dimensionMeta(dimensions),
preview, preview,
authorId, authorId,
siteId siteId
@ -950,6 +1013,7 @@ class Assets {
locale, locale,
title: entry.title, title: entry.title,
hasPreview: Boolean(preview), hasPreview: Boolean(preview),
...dimensionMeta(dimensions),
createdAt: entry.createdAt, createdAt: entry.createdAt,
updatedAt: entry.updatedAt updatedAt: entry.updatedAt
} }
@ -1226,16 +1290,35 @@ class Assets {
} }
/** /**
* Rename an asset, in both of the rows that describe it. * Rename an asset, move it to another folder, or both.
*
* One operation rather than two, because to a storage target they are the same one: a file's path
* under a target is its folder and its name together, so either half changing is the same copy
* moved to a new place. The wiki's own rows are what decide where that is, and this rewrites them
* before asking every target to follow.
* *
* A locale is part of that address too -- a target brackets its tree by locale unless the site says
* otherwise -- so moving between locales is the same operation again, and the folder the file lands
* in is that locale's, created if that locale does not have one yet.
*
* @param fileName What to call it, sanitized. Keeps its current name when absent.
* @param folderPath Which folder to put it in, from the site root, empty for the root itself.
* Created if it does not exist. Stays where it is when absent.
* @param locale Which locale's tree to put it in. Stays in its own when absent.
* @returns The updated metadata, or null if there is no such asset on this site * @returns The updated metadata, or null if there is no such asset on this site
*/ */
async renameAsset(siteId: string, id: string, fileName: string): Promise<Asset | null> { async moveAsset(
siteId: string,
id: string,
{ fileName, folderPath, locale }: { fileName?: string; folderPath?: string; locale?: string },
actorId?: string
): Promise<Asset | null> {
const asset = await this.getAsset(siteId, id) const asset = await this.getAsset(siteId, id)
if (!asset) { const entry = await WIKI.models.tree.getById(id)
if (!asset || !entry) {
return null return null
} }
const safeName = sanitizeFileName(fileName) const safeName = fileName === undefined ? asset.fileName : sanitizeFileName(fileName)
if (!safeName) { if (!safeName) {
throw new CustomError('assetInvalidFileName', 'This file name cannot be used.') throw new CustomError('assetInvalidFileName', 'This file name cannot be used.')
} }
@ -1243,41 +1326,101 @@ class Assets {
if (!fileExt) { if (!fileExt) {
throw new CustomError('assetInvalidFileName', 'The file name must keep a file extension.') throw new CustomError('assetInvalidFileName', 'The file name must keep a file extension.')
} }
// -> The same two rules an upload is held to: renaming is another way of arriving at a name, and const destination =
// `readme.pdf` renamed to `readme.md` would land on the page of that name just as squarely folderPath === undefined ? asset.folderPath : normalizeFolderPath(folderPath)
const entry = await WIKI.models.tree.getById(id) const destinationLocale = locale || entry.locale
if (entry) { const isRenamed = safeName !== asset.fileName
await this.guardAgainstPageCollision({ // -> One question rather than two: a file in another locale's tree is somewhere else as surely as
// one in another folder, and everything below has to treat the pair as the destination
const isRelocated = destination !== asset.folderPath || destinationLocale !== entry.locale
if (!isRenamed && !isRelocated) {
return asset
}
// -> The same two rules an upload is held to, asked of where it is GOING: renaming is another way
// of arriving at a name, and `readme.pdf` renamed to `readme.md` -- or carried into the folder
// holding the page `readme` -- would land on that page just as squarely
await this.guardAgainstPageCollision({
siteId,
locale: destinationLocale,
folderPath: destination,
fileName: safeName,
fileExt
})
let storedName = safeName
if (isRelocated) {
/*
Asked before anything is written, because the write cannot take it back: the tree entry is
MOVED rather than rewritten -- deleted and re-added, so that the folders the destination needs
get created and the ones it leaves stop counting it -- and a name refused halfway through that
would leave the asset row with no entry pointing at it. `addAsset` still settles a name that
was taken in between by suffixing it, which keeps the two rows consistent where failing would
not; `storedName` is read back off the row rather than assumed for exactly that case.
*/
const occupant = await WIKI.models.tree.getEntryAt({
siteId, siteId,
locale: entry.locale, locale: destinationLocale,
folderPath: decodeTreePath(entry.folderPath ?? '') ?? '', parentPath: destination,
fileName: safeName
})
if (occupant) {
throw new CustomError(
'assetNameTakenByEntry',
`A ${occupant.type} with this name already exists there.`,
409
)
}
await WIKI.models.tree.deleteEntry(id)
const moved = await WIKI.models.tree.addAsset({
id,
parentPath: destination,
fileName: safeName, fileName: safeName,
fileExt title: safeName,
locale: destinationLocale,
siteId,
tags: entry.tags,
meta: entry.meta
}) })
storedName = moved.fileName
} else {
await WIKI.models.tree.renameEntry({ id, fileName: safeName, title: safeName })
} }
const resolvedMime = mime.getType(safeName) ?? asset.mimeType // -> Off the stored name rather than off the requested one, since the two differ where a move had
// to settle a collision
const storedExt = extensionOf(storedName)
const resolvedMime = mime.getType(storedName) ?? asset.mimeType
await WIKI.models.tree.renameEntry({ id, fileName: safeName, title: safeName })
await WIKI.db await WIKI.db
.update(assetsTable) .update(assetsTable)
.set({ .set({
fileName: safeName, fileName: storedName,
fileExt, fileExt: storedExt,
mimeType: resolvedMime, mimeType: resolvedMime,
kind: kindOf(resolvedMime, fileExt), kind: kindOf(resolvedMime, storedExt),
updatedAt: sql`now()` updatedAt: sql`now()`
}) })
.where(eq(assetsTable.id, id)) .where(eq(assetsTable.id, id))
// -> The tree carries its own copy of these, and it is what a folder listing reads // -> The tree carries its own copy of these, and it is what a folder listing reads. The bytes are
// untouched by either half of this, so whatever was measured of them is carried across rather
// than rebuilt: nothing here has the file in hand to measure it again.
await WIKI.db await WIKI.db
.update(treeTable) .update(treeTable)
.set({ meta: { fileSize: asset.fileSize, fileExt, mimeType: resolvedMime } }) .set({
meta: {
fileSize: asset.fileSize,
fileExt: storedExt,
mimeType: resolvedMime,
...dimensionMeta(dimensionsOf(asset))
}
})
.where(eq(treeTable.id, id)) .where(eq(treeTable.id, id))
// -> Every target holding this asset lays its copy out by path, so each of them has a file to // -> Every target holding this asset lays its copy out by path, so each of them has a file to
// move now that the tree rows have been rewritten // move now that the tree rows have been rewritten
if (entry) { await this.relocateAssets(
await this.relocateAssets(siteId, [ siteId,
[
{ {
id, id,
previous: { previous: {
@ -1286,20 +1429,24 @@ class Assets {
fileName: asset.fileName fileName: asset.fileName
} }
} }
]) ],
} actorId
)
// -> Both ends of the move: the name it left, and the name it took, which something else may have // -> Both ends of the move: the name it left, and the name it took, which something else may have
// been resolved at before it was freed up // been resolved at before it was freed up
this.forgetPath(siteId, asset.folderPath, asset.fileName) this.forgetPath(siteId, asset.folderPath, asset.fileName)
this.forgetPath(siteId, asset.folderPath, safeName) this.forgetPath(siteId, destination, storedName)
await this.dropCachedContent([id]) await this.dropCachedContent([id])
WIKI.models.hooks.emit('asset:rename', { WIKI.models.hooks.emit('asset:rename', {
id, id,
fileName: safeName, fileName: storedName,
previousFileName: asset.fileName, previousFileName: asset.fileName,
folderPath: asset.folderPath, folderPath: destination,
previousFolderPath: asset.folderPath,
locale: destinationLocale,
previousLocale: entry.locale,
siteId siteId
}) })

@ -318,6 +318,69 @@ class Navigation {
return { navigationMode: mode, navigationId: navId } return { navigationMode: mode, navigationId: navId }
} }
/**
* Repoint the sidebars of a subtree that has just moved.
*
* A menu is inherited from where an entry SITS -- the nearest ancestor that overrides or hides, and
* the site menu of its locale when none does -- so a folder that changes parents, or locales, takes
* a subtree full of entries pointing at a menu that is no longer above them. Left alone, a French
* page would show the English sidebar, which is the one thing a translated wiki cannot do.
*
* Only entries still on `inherit` are repointed, and only those not sitting under an override or a
* hide WITHIN the moved subtree: a menu written for a branch belongs to that branch's own entry, is
* keyed by its id, and travels with it.
*
* Called after the rows are at their destination, since what is above them is what decides this.
*
* @param folderId The moved folder, whose own mode is considered along with its descendants'
* @param locale The locale it now sits in
* @param folderPath Encoded ltree path of its new parent, empty at the site root
* @param fileName Its path name, which with `folderPath` is what its descendants sit under
*/
async repointMovedSubtree({
siteId,
folderId,
locale,
folderPath,
fileName
}: {
siteId: string
folderId: string
locale: string
folderPath: string
fileName: string
}): Promise<void> {
const inherited = await this.ancestorNavId(siteId, locale, folderPath)
const fullPath = folderPath ? `${folderPath}.${fileName}` : fileName
// -> The folder itself first: it is not under its own path, so the cascade below passes it over
await WIKI.db
.update(treeTable)
.set({ navigationId: inherited })
.where(and(eq(treeTable.id, folderId), eq(treeTable.navigationMode, 'inherit')))
// -> The same walk `updateNavigation` does when a mode changes, over the subtree that moved
await WIKI.db.execute(sql`
UPDATE tree tt
SET "navigationId" = ${inherited}
WHERE tt."siteId" = ${siteId}
AND tt."locale" = ${locale}
AND tt.tree IN ('page', 'folder')
AND tt."folderPath" <@ ${fullPath}::ltree
AND tt."navigationMode" = 'inherit'
AND NOT EXISTS (
SELECT 1
FROM tree tc
WHERE tc."siteId" = ${siteId}
AND tc."locale" = ${locale}
AND tc.tree IN ('page', 'folder')
AND tc."folderPath" <@ ${fullPath}::ltree
AND (tc."folderPath" || tc."fileName") @> tt."folderPath"
AND tc."navigationMode" IN ('override', 'hide')
)
`)
}
} }
export const navigation = new Navigation() export const navigation = new Navigation()

@ -633,6 +633,23 @@ class Pages {
} }
} }
/**
* Take a set of pages out of their locale groups, for a folder that moved to another locale.
*
* The same rule `movePage` applies to one page, applied to everything under a folder that crossed
* locales at once. It has to happen before their `locale` column is rewritten: a group holds one
* page per locale and the index enforcing that would refuse the second arrival otherwise.
*
* One page at a time rather than in bulk, because the groups they belong to are not the same group
* and each has to be looked at for whether it still has anybody left in it. A folder move is a rare
* and deliberate act, so the queries are worth the plainness.
*/
async detachFromLocaleGroups(siteId: string, ids: string[]): Promise<void> {
for (const id of ids) {
await this.detachFromLocaleGroup(siteId, id)
}
}
/** /**
* Take one page out of its locale group, leaving the rest of the set related to each other. * Take one page out of its locale group, leaving the rest of the set related to each other.
* *

@ -7,8 +7,10 @@ import {
encodeTreePath, encodeTreePath,
generateHash, generateHash,
generatePathHash, generatePathHash,
normalizeFolderPath,
normalizePagePath normalizePagePath
} from '../helpers/common.ts' } from '../helpers/common.ts'
import type { PageActor } from './pages.ts'
/** What a tree entry can be. Mirrors the `treeType` enum in the schema. */ /** What a tree entry can be. Mirrors the `treeType` enum in the schema. */
export type TreeItemType = 'folder' | 'page' | 'asset' export type TreeItemType = 'folder' | 'page' | 'asset'
@ -39,12 +41,22 @@ export interface TreeItem {
updatedAt: Date updatedAt: Date
/** Folders only — how many entries the folder holds. */ /** Folders only — how many entries the folder holds. */
childrenCount?: number childrenCount?: number
/**
* Folders only how far the folder icon is rotated around the colour wheel, in degrees.
*
* Absent on a folder nobody has coloured, which is the same thing as zero: the icon is drawn
* yellow and a rotation of nothing leaves it there.
*/
hue?: number
/** Folders only — whether this folder is a parent of the one being listed, not a child of it. */ /** Folders only — whether this folder is a parent of the one being listed, not a child of it. */
isAncestor?: boolean isAncestor?: boolean
/** Assets only. */ /** Assets only. */
fileSize?: number fileSize?: number
fileExt?: string fileExt?: string
mimeType?: string mimeType?: string
/** Image assets only — in pixels, as displayed. Absent when the dimensions were never read. */
width?: number
height?: number
/** Pages only. */ /** Pages only. */
editor?: string editor?: string
description?: string description?: string
@ -168,6 +180,9 @@ function toTreeItem(row: TreeRow, depth: number, parentPath: string): TreeItem {
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
...(row.type === 'folder' && { ...(row.type === 'folder' && {
childrenCount: row.meta?.children ?? 0, childrenCount: row.meta?.children ?? 0,
// -> No default: an uncoloured folder carries no hue rather than a zero, which is what lets the
// interface draw it with no filter at all
...(row.meta?.hue ? { hue: row.meta.hue } : {}),
// -> Shorter than the folder being listed means it sits above it, so it came from // -> Shorter than the folder being listed means it sits above it, so it came from
// `includeAncestors` / `includeRootFolders` rather than from the listing itself // `includeAncestors` / `includeRootFolders` rather than from the listing itself
isAncestor: folderPath.length < parentPath.length isAncestor: folderPath.length < parentPath.length
@ -175,7 +190,12 @@ function toTreeItem(row: TreeRow, depth: number, parentPath: string): TreeItem {
...(row.type === 'asset' && { ...(row.type === 'asset' && {
fileSize: row.meta?.fileSize ?? 0, fileSize: row.meta?.fileSize ?? 0,
fileExt: row.meta?.fileExt ?? '', fileExt: row.meta?.fileExt ?? '',
mimeType: row.meta?.mimeType ?? '' mimeType: row.meta?.mimeType ?? '',
// -> No default, unlike the rest: a file whose dimensions were never read has none rather than
// being zero pixels across, and the file manager shows the row only for one that has them
...(row.meta?.width && row.meta?.height
? { width: row.meta.width, height: row.meta.height }
: {})
}), }),
...(row.type === 'page' && { ...(row.type === 'page' && {
editor: row.meta?.editor ?? '', editor: row.meta?.editor ?? '',
@ -809,7 +829,7 @@ class Tree {
siteId, siteId,
meta: { children: 0 } meta: { children: 0 }
}) })
await this.countTowardsFolderAt(siteId, ancestor.folderPath, 1) await this.countTowardsFolderAt(siteId, effectiveLocale, ancestor.folderPath, 1)
} }
} }
@ -828,7 +848,7 @@ class Tree {
}) })
.returning() .returning()
await this.countTowardsFolderAt(siteId, path, 1) await this.countTowardsFolderAt(siteId, effectiveLocale, path, 1)
WIKI.logger.debug(`Created folder ${inserted[0].id} successfully.`) WIKI.logger.debug(`Created folder ${inserted[0].id} successfully.`)
return inserted[0] as TreeRow return inserted[0] as TreeRow
@ -908,19 +928,35 @@ class Tree {
WIKI.logger.debug(`Renaming folder ${folder.id} from ${oldPath} to ${newPath}...`) WIKI.logger.debug(`Renaming folder ${folder.id} from ${oldPath} to ${newPath}...`)
// -> Direct children carry the old path verbatim; deeper ones carry it as a prefix, and keep /*
// whatever they had below it Direct children carry the old path verbatim; deeper ones carry it as a prefix, and keep whatever
they had below it.
Scoped to this folder's locale, as everything below is: the tree holds every translation side by
side, so a path names one folder per locale, and without this renaming the English `/guides`
dragged the French one's children to a path their own folder row did not have.
*/
await WIKI.db await WIKI.db
.update(treeTable) .update(treeTable)
.set({ folderPath: newPath }) .set({ folderPath: newPath })
.where(and(eq(treeTable.siteId, folder.siteId), eq(treeTable.folderPath, oldPath))) .where(
and(
eq(treeTable.siteId, folder.siteId),
eq(treeTable.locale, folder.locale),
eq(treeTable.folderPath, oldPath)
)
)
await WIKI.db await WIKI.db
.update(treeTable) .update(treeTable)
.set({ .set({
folderPath: sql`${newPath}::ltree || subpath(${treeTable.folderPath}, nlevel(${newPath}::ltree))` folderPath: sql`${newPath}::ltree || subpath(${treeTable.folderPath}, nlevel(${newPath}::ltree))`
}) })
.where( .where(
and(eq(treeTable.siteId, folder.siteId), sql`${treeTable.folderPath} <@ ${oldPath}::ltree`) and(
eq(treeTable.siteId, folder.siteId),
eq(treeTable.locale, folder.locale),
sql`${treeTable.folderPath} <@ ${oldPath}::ltree`
)
) )
const fullPath = folder.folderPath ? `${decodeTreePath(folder.folderPath)}/${name}` : name const fullPath = folder.folderPath ? `${decodeTreePath(folder.folderPath)}/${name}` : name
@ -930,7 +966,7 @@ class Tree {
.where(eq(treeTable.id, folder.id)) .where(eq(treeTable.id, folder.id))
.returning() .returning()
const movedPages = await this.refreshDescendantPaths(folder.siteId, newPath) const movedPages = await this.refreshDescendantPaths(folder.siteId, folder.locale, newPath)
// -> Only moved, never rewritten: none of these pages changed, so the copy a target holds is // -> Only moved, never rewritten: none of these pages changed, so the copy a target holds is
// still the right contents at the wrong name // still the right contents at the wrong name
@ -963,6 +999,7 @@ class Tree {
.where( .where(
and( and(
eq(treeTable.siteId, folder.siteId), eq(treeTable.siteId, folder.siteId),
eq(treeTable.locale, folder.locale),
eq(treeTable.type, 'asset'), eq(treeTable.type, 'asset'),
sql`${treeTable.folderPath} <@ ${newPath}::ltree` sql`${treeTable.folderPath} <@ ${newPath}::ltree`
) )
@ -992,6 +1029,488 @@ class Tree {
return updated[0] as TreeRow return updated[0] as TreeRow
} }
/**
* Copy a folder, and everything under it, to another parent another locale, and another name.
*
* Unlike a move, nothing here is a rewrite: every folder, page and file under the source gets a new
* row of its own, so the two trees go their separate ways from this moment. That is also why each
* copy goes through the model that owns it rather than through an INSERT ... SELECT a copied page
* is rendered, indexed, given its own history and written to every storage target, and a copied file
* gets its own thumbnail and its own bytes on every target. A folder of a few hundred pages is
* therefore a slow request, and deliberately so: half a copy is worse than a slow one.
*
* What is deliberately not carried across:
*
* - **Aliases**, which are unique per site: a copy cannot have the original's, and inventing one is
* not this operation's business.
* - **Translation sets**: a copy is a new page, not another language's version of an existing one.
* - **Sidebar overrides**, so the copies inherit the menu of wherever they land which for a copy
* into another locale is the only sensible answer anyway.
*
* Folder colours ARE carried across, since they are how somebody has arranged their tree.
*
* @param folderPath Slash-separated path of the folder to copy into, empty for the site root.
* @param pathName What to call the copy. The name it collides on, and the one that must be free.
* @param title The copy's title.
* @param locale The locale to copy into. The source's own when absent.
*/
async duplicateFolder({
folderId,
folderPath,
pathName,
title,
locale,
actor
}: {
folderId: string
folderPath: string
pathName: string
title: string
locale?: string
/** Who is copying, which is who the copied pages and files are authored by. */
actor: PageActor
}): Promise<TreeRow> {
const source = await this.getFolderById(folderId)
if (!source) {
throw new CustomError('treeInvalidFolder', 'This folder does not exist.', 404)
}
const siteId = source.siteId
const destinationLocale = locale || source.locale
const requested = normalizeFolderPath(folderPath)
const sourcePath = childPathOf(source)
const ownPath = decodeTreePath(sourcePath) ?? ''
// -> On the paths, before anything is created: a folder copied into its own subtree would be
// copying into what it is still reading from
if (
destinationLocale === source.locale &&
(requested === ownPath || requested.startsWith(`${ownPath}/`))
) {
throw new CustomError(
'treeFolderIntoItself',
'A folder cannot be copied into itself or into one of its own subfolders.',
400
)
}
/*
Read before the copy starts, and in one go: the walk below creates folders as it goes, and a
scan that ran alongside it would find them. Shallowest first, so each entry's own parent has
already been created by the time it is reached.
*/
const descendants = await WIKI.db
.select({
id: treeTable.id,
type: treeTable.type,
folderPath: treeTable.folderPath,
fileName: treeTable.fileName,
title: treeTable.title,
// -> Cast because Drizzle types a jsonb column as `{}` until something says otherwise, and
// what this reads out of it is the folder's colour
meta: sql<Record<string, any>>`${treeTable.meta}`
})
.from(treeTable)
.where(
and(
eq(treeTable.siteId, siteId),
eq(treeTable.locale, source.locale),
sql`${treeTable.folderPath} <@ ${sourcePath}::ltree`
)
)
.orderBy(sql`nlevel(${treeTable.folderPath})`, treeTable.fileName)
// -> `createFolder` is what refuses a name already taken there, so this is also the collision
// check -- and it makes it before creating anything, including the folders the path needs
const copy = await this.createFolder({
siteId,
locale: destinationLocale,
parentPath: requested,
pathName,
title
})
if (source.meta?.hue) {
await this.setFolderColor({ folderId: copy.id, hue: source.meta.hue })
}
const copyPath = childPathOf(copy)
const newPrefix = decodeTreePath(copyPath) ?? ''
/** Where an entry under the source sits under the copy, as a slash-separated folder path. */
const mapFolder = (path: string | null) =>
`${newPrefix}${(decodeTreePath(path ?? '') ?? '').slice(ownPath.length)}`
WIKI.logger.debug(
`Copying folder ${source.id} and ${descendants.length} descendant(s) to ${destinationLocale}:${copyPath}...`
)
for (const entry of descendants) {
const parentPath = mapFolder(entry.folderPath)
switch (entry.type) {
case 'folder': {
const folderCopy = await this.createFolder({
siteId,
locale: destinationLocale,
parentPath,
pathName: entry.fileName,
title: entry.title
})
if (entry.meta?.hue) {
await this.setFolderColor({ folderId: folderCopy.id, hue: entry.meta.hue })
}
break
}
case 'page': {
const page = await WIKI.models.pages.getPage({
siteId,
id: entry.id,
withContent: true,
withPassword: true
})
if (!page) {
break
}
await WIKI.models.pages.createPage(
siteId,
{
path: parentPath ? `${parentPath}/${entry.fileName}` : entry.fileName,
locale: destinationLocale,
title: page.title,
description: page.description ?? '',
icon: page.icon ?? '',
editor: page.editor,
content: page.content ?? '',
render: page.render,
publishState: page.publishState,
publishStartDate: page.publishStartDate?.toISOString() ?? null,
publishEndDate: page.publishEndDate?.toISOString() ?? null,
isBrowsable: page.isBrowsable,
isSearchable: page.isSearchable,
password: page.password ?? '',
relations: page.relations,
tags: page.tags,
allowComments: page.allowComments,
allowContributions: page.allowContributions,
allowRatings: page.allowRatings,
showSidebar: page.showSidebar,
showTags: page.showTags
},
actor
)
break
}
case 'asset': {
const content = await WIKI.models.assets.getContent(entry.id)
if (!content) {
// -> Its bytes are gone, which is a broken file rather than a reason to fail the copy
WIKI.logger.warn(`Skipped copying ${entry.fileName}: it has no content.`)
break
}
await WIKI.models.assets.upload({
siteId,
locale: destinationLocale,
folderPath: parentPath,
fileName: entry.fileName,
mimeType: content.mimeType,
data: content.data,
authorId: actor.id
})
break
}
}
}
WIKI.logger.debug(`Copied folder ${source.id} successfully.`)
// -> Read back rather than returned as created: the copy was an empty folder at that point, and
// answering with a child count of zero for a folder that now holds a branch is a lie the
// caller would draw
return (await this.getFolderById(copy.id)) ?? copy
}
/**
* Colour a folder's icon, or put it back to the colour every folder starts out.
*
* Stored as a hue rotation in degrees rather than as a colour, because that is what is actually
* applied: the folder icon is one yellow image and the interface turns it around the colour wheel,
* so a wiki that restyles that icon keeps every folder's choice meaningful. Zero is therefore not a
* colour but the absence of one, and is stored by removing the key -- a folder nobody has coloured
* and one put back to yellow are the same folder.
*
* `meta` also carries the folder's child count, so this edits the one key rather than replacing the
* object: the count is maintained in postgres by whoever adds or removes an entry, and a
* read-modify-write here would lose whatever landed in between.
*
* @param hue Degrees around the colour wheel, 0 to 359. Zero clears it.
*/
async setFolderColor({ folderId, hue }: { folderId: string; hue: number }): Promise<TreeRow> {
const folder = await this.getFolderById(folderId)
if (!folder) {
throw new CustomError('treeInvalidFolder', 'This folder does not exist.', 404)
}
if (!Number.isInteger(hue) || hue < 0 || hue > 359) {
throw new CustomError('treeInvalidHue', 'A folder colour must be a hue between 0 and 359.')
}
const updated = await WIKI.db
.update(treeTable)
.set({
meta: hue
? sql`jsonb_set(${treeTable.meta}, '{hue}', to_jsonb(${hue}::int))`
: sql`${treeTable.meta} - 'hue'`,
updatedAt: sql`now()`
})
.where(eq(treeTable.id, folder.id))
.returning()
return updated[0] as TreeRow
}
/**
* Move a folder to another parent, another locale, or both everything under it going along.
*
* The same rewrite a rename does, with the folder's own parent changing rather than its name, and
* with a locale that may change too. That makes it several operations at once, and the order below
* is what keeps them consistent:
*
* 1. **Nothing is created before the move is known to be legal.** The destination is resolved with
* `createIfMissing`, so a folder asked to move inside itself would otherwise leave a new folder
* behind on the way to being refused. Both refusals are therefore decided on the paths alone,
* before anything is looked up.
* 2. **Pages leave their translation sets before their locale is rewritten**, since a set holds one
* page per locale and the index enforcing it would refuse the second arrival.
* 3. **The rows move, then everything derived from them is rebuilt** the hashes an entry is found
* by, the second copy of its path each page keeps, the sidebar each entry inherits, and the copy
* every storage target holds. Each of those reads the tree back rather than being computed from
* the move, so there is one answer to where a thing is and it is the row.
*
* A collision cannot come from below: the destination is refused if anything but a page is already
* called this there, and nothing can sit under a folder path that does not exist so once the
* folder itself fits, every descendant does.
*
* @param folderPath Slash-separated path of the folder to move into, empty for the site root.
* Created if it does not exist.
* @param locale The locale to move it to. Stays in its own when absent.
*/
async moveFolder({
folderId,
folderPath,
locale,
actorId
}: {
folderId: string
folderPath: string
locale?: string
/** Who is moving it, for a target that records who moved a file. */
actorId?: string
}): Promise<TreeRow> {
const folder = await this.getFolderById(folderId)
if (!folder) {
throw new CustomError('treeInvalidFolder', 'This folder does not exist.', 404)
}
const siteId = folder.siteId
const destinationLocale = locale || folder.locale
const oldParentPath = folder.folderPath ?? ''
const oldPath = childPathOf(folder)
const requested = normalizeFolderPath(folderPath)
const ownPath = decodeTreePath(oldPath) ?? ''
// -> Decided on the paths, before the destination is resolved: resolving it would create it, and
// a folder moved inside itself would take its own subtree out of the tree entirely
if (
destinationLocale === folder.locale &&
(requested === ownPath || requested.startsWith(`${ownPath}/`))
) {
throw new CustomError(
'treeFolderIntoItself',
'A folder cannot be moved into itself or into one of its own subfolders.',
400
)
}
const parent = requested
? await this.getFolder({
path: requested,
locale: destinationLocale,
siteId,
createIfMissing: true
})
: null
const newParentPath = parent ? childPathOf(parent) : ''
if (newParentPath === oldParentPath && destinationLocale === folder.locale) {
return folder
}
// -> As on the way in: a page may share the name of the folder holding the pages below it, an
// asset may not, and neither may another folder
const existing = await WIKI.db
.select({ type: treeTable.type })
.from(treeTable)
.where(
and(
ne(treeTable.id, folder.id),
eq(treeTable.siteId, siteId),
eq(treeTable.locale, destinationLocale),
eq(treeTable.folderPath, newParentPath),
eq(treeTable.fileName, folder.fileName),
ne(treeTable.type, 'page')
)
)
.limit(1)
if (existing.length > 0) {
throw new CustomError(
'treeFolderDuplicate',
existing[0].type === 'folder'
? 'A folder with this path name already exists there.'
: 'A file with this path name already exists there.',
409
)
}
const newPath = newParentPath ? `${newParentPath}.${folder.fileName}` : folder.fileName
const isLocaleChange = destinationLocale !== folder.locale
WIKI.logger.debug(
`Moving folder ${folder.id} from ${folder.locale}:${oldPath} to ${destinationLocale}:${newPath}...`
)
const descendants = await WIKI.db
.select({ id: treeTable.id, type: treeTable.type })
.from(treeTable)
.where(
and(
eq(treeTable.siteId, siteId),
eq(treeTable.locale, folder.locale),
sql`${treeTable.folderPath} <@ ${oldPath}::ltree`
)
)
const pageIds = descendants.filter((row) => row.type === 'page').map((row) => row.id)
if (isLocaleChange && pageIds.length > 0) {
await WIKI.models.pages.detachFromLocaleGroups(siteId, pageIds)
}
// -> Direct children carry the old path verbatim; deeper ones carry it as a prefix, and keep
// whatever they had below it
const movedLocale = isLocaleChange ? { locale: destinationLocale } : {}
await WIKI.db
.update(treeTable)
.set({ folderPath: newPath, ...movedLocale })
.where(
and(
eq(treeTable.siteId, siteId),
eq(treeTable.locale, folder.locale),
eq(treeTable.folderPath, oldPath)
)
)
await WIKI.db
.update(treeTable)
.set({
// -> What is dropped is however deep the OLD path was, which is what the row carries; a rename
// can use either because the two are the same depth, and a move cannot
folderPath: sql`${newPath}::ltree || subpath(${treeTable.folderPath}, nlevel(${oldPath}::ltree))`,
...movedLocale
})
.where(
and(
eq(treeTable.siteId, siteId),
eq(treeTable.locale, folder.locale),
sql`${treeTable.folderPath} <@ ${oldPath}::ltree`
)
)
if (isLocaleChange && pageIds.length > 0) {
// -> A page keeps its own copy of the locale, as it does of its path, and it is the one a
// reader's request resolves against
await WIKI.db
.update(pagesTable)
.set({ locale: destinationLocale })
.where(and(eq(pagesTable.siteId, siteId), inArray(pagesTable.id, pageIds)))
}
const fullPath = newParentPath
? `${decodeTreePath(newParentPath)}/${folder.fileName}`
: folder.fileName
const updated = await WIKI.db
.update(treeTable)
.set({
folderPath: newParentPath,
locale: destinationLocale,
hash: generateHash(fullPath),
updatedAt: sql`now()`
})
.where(eq(treeTable.id, folder.id))
.returning()
const movedPages = await this.refreshDescendantPaths(siteId, destinationLocale, newPath)
// -> Only moved, never rewritten: none of these pages changed, so the copy a target holds is
// still the right contents at the wrong place
for (const page of movedPages) {
await WIKI.models.storage.relocatePage(
{
id: page.id,
siteId,
actorId,
locale: destinationLocale,
path: page.path,
contentType: page.contentType
},
{ locale: folder.locale, path: page.previousPath }
)
}
// -> A storage target that lays its content out by path has every one of those files to move.
// Asked for after the rows are correct, so that where each file belongs is read off the tree
// rather than recomputed from the move.
const movedAssets = await WIKI.db
.select({
id: treeTable.id,
folderPath: treeTable.folderPath,
fileName: treeTable.fileName
})
.from(treeTable)
.where(
and(
eq(treeTable.siteId, siteId),
eq(treeTable.locale, destinationLocale),
eq(treeTable.type, 'asset'),
sql`${treeTable.folderPath} <@ ${newPath}::ltree`
)
)
const newPrefix = decodeTreePath(newPath)!
const oldPrefix = decodeTreePath(oldPath)!
await WIKI.models.assets.relocateAssets(
siteId,
movedAssets.map((row) => ({
id: row.id,
previous: {
locale: folder.locale,
// -> Where it was: the same place it is now, with the moved folder's path put back. Sliced
// rather than replaced, since the segment that moved can occur again further down.
folderPath: `${oldPrefix}${(decodeTreePath(row.folderPath ?? '') ?? '').slice(newPrefix.length)}`,
fileName: row.fileName
}
})),
actorId
)
// -> Every asset under it is served from a different path now, and nothing about the assets
// themselves changed for the file cache to notice
WIKI.models.assets.forgetAllPaths()
// -> What a sidebar is inherited from is where an entry SITS, and everything under here now sits
// somewhere else
await WIKI.models.navigation.repointMovedSubtree({
siteId,
folderId: folder.id,
locale: destinationLocale,
folderPath: newParentPath,
fileName: folder.fileName
})
// -> The folder it left holds one fewer entry, and the one it arrived in holds one more
await this.countTowardsFolderAt(siteId, folder.locale, oldParentPath, -1)
await this.countTowardsFolderAt(siteId, destinationLocale, newParentPath, 1)
WIKI.logger.debug(`Moved folder ${folder.id} successfully.`)
return updated[0] as TreeRow
}
/** /**
* Rewrite where everything at or below a folder now sits. * Rewrite where everything at or below a folder now sits.
* *
@ -1008,11 +1527,17 @@ class Tree {
* from here. What is deliberately not touched is `updatedAt`: the folder moved, the pages under it * from here. What is deliberately not touched is `updatedAt`: the folder moved, the pages under it
* did not change, and marking a few hundred of them as freshly edited would say otherwise. * did not change, and marking a few hundred of them as freshly edited would say otherwise.
* *
* Scoped to ONE LOCALE, and every caller has to say which. The tree holds every translation side by
* side under the same paths, so `folderPath <@ 'guides'` is the English subtree and the French one
* and every other and a rewrite that took them all in would report another locale's pages as
* having moved, sending a storage target to move files that never went anywhere.
*
* @returns Where each page moved from and to, for the copies a storage target keeps of them. The * @returns Where each page moved from and to, for the copies a storage target keeps of them. The
* old path is only knowable from here a moment later the row no longer says where it was. * old path is only knowable from here a moment later the row no longer says where it was.
*/ */
private async refreshDescendantPaths( private async refreshDescendantPaths(
siteId: string, siteId: string,
locale: string,
path: string path: string
): Promise< ): Promise<
{ id: string; locale: string; previousPath: string; path: string; contentType: string }[] { id: string; locale: string; previousPath: string; path: string; contentType: string }[]
@ -1029,7 +1554,13 @@ class Tree {
}) })
.from(treeTable) .from(treeTable)
.leftJoin(pagesTable, eq(pagesTable.id, treeTable.id)) .leftJoin(pagesTable, eq(pagesTable.id, treeTable.id))
.where(and(eq(treeTable.siteId, siteId), sql`${treeTable.folderPath} <@ ${path}::ltree`)) .where(
and(
eq(treeTable.siteId, siteId),
eq(treeTable.locale, locale),
sql`${treeTable.folderPath} <@ ${path}::ltree`
)
)
const movedPages = [] const movedPages = []
for (const row of rows) { for (const row of rows) {
@ -1076,12 +1607,22 @@ class Tree {
const path = childPathOf(folder) const path = childPathOf(folder)
WIKI.logger.debug(`Deleting folder ${folder.id} at path ${path}...`) WIKI.logger.debug(`Deleting folder ${folder.id} at path ${path}...`)
// -> `<@` is "at or below", and the folder itself is not under its own child path, so this takes /*
// the descendants and leaves the row that owns them `<@` is "at or below", and the folder itself is not under its own child path, so this takes the
descendants and leaves the row that owns them.
Scoped to this folder's locale, as the rename and the move beside it are: the tree holds every
translation side by side under the same paths, so without it deleting the English `/guides`
took the French one's pages and files with it and left its folder rows behind, empty.
*/
const deleted = await WIKI.db const deleted = await WIKI.db
.delete(treeTable) .delete(treeTable)
.where( .where(
and(eq(treeTable.siteId, folder.siteId), sql`${treeTable.folderPath} <@ ${path}::ltree`) and(
eq(treeTable.siteId, folder.siteId),
eq(treeTable.locale, folder.locale),
sql`${treeTable.folderPath} <@ ${path}::ltree`
)
) )
.returning({ .returning({
id: treeTable.id, id: treeTable.id,
@ -1096,7 +1637,7 @@ class Tree {
// -> Any of them may have owned a sidebar menu keyed by its own id, the folder included // -> Any of them may have owned a sidebar menu keyed by its own id, the folder included
await WIKI.models.navigation.deleteNavForEntries([...deleted.map((n) => n.id), folder.id]) await WIKI.models.navigation.deleteNavForEntries([...deleted.map((n) => n.id), folder.id])
await this.countTowardsFolderAt(folder.siteId, folder.folderPath ?? '', -1) await this.countTowardsFolderAt(folder.siteId, folder.locale, folder.folderPath ?? '', -1)
WIKI.logger.debug(`Deleted folder ${folder.id} and ${deleted.length} descendant(s).`) WIKI.logger.debug(`Deleted folder ${folder.id} and ${deleted.length} descendant(s).`)
@ -1272,7 +1813,7 @@ class Tree {
return false return false
} }
await WIKI.db.delete(treeTable).where(eq(treeTable.id, id)) await WIKI.db.delete(treeTable).where(eq(treeTable.id, id))
await this.countTowardsFolderAt(entry.siteId, entry.folderPath ?? '', -1) await this.countTowardsFolderAt(entry.siteId, entry.locale, entry.folderPath ?? '', -1)
return true return true
} }
@ -1342,7 +1883,7 @@ class Tree {
}) })
.returning() .returning()
await this.countTowardsFolderAt(siteId, path, 1) await this.countTowardsFolderAt(siteId, locale, path, 1)
return inserted[0] as TreeRow return inserted[0] as TreeRow
} }
@ -1429,7 +1970,12 @@ class Tree {
* *
* An empty path is the site root, which is not a folder and has nothing to count. * An empty path is the site root, which is not a folder and has nothing to count.
*/ */
private async countTowardsFolderAt(siteId: string, path: string, delta: number): Promise<void> { private async countTowardsFolderAt(
siteId: string,
locale: string,
path: string,
delta: number
): Promise<void> {
if (!path) { if (!path) {
return return
} }
@ -1442,6 +1988,10 @@ class Tree {
.where( .where(
and( and(
eq(treeTable.siteId, siteId), eq(treeTable.siteId, siteId),
// -> The tree holds every translation side by side, so a path names one folder PER LOCALE:
// without this, adding a file to the English `/guides` also counted it against the French
// one, and a folder that moved between locales decremented a folder it never sat in
eq(treeTable.locale, locale),
eq(treeTable.folderPath, location.folderPath), eq(treeTable.folderPath, location.folderPath),
eq(treeTable.fileName, location.fileName), eq(treeTable.fileName, location.fileName),
eq(treeTable.type, 'folder') eq(treeTable.type, 'folder')

@ -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.
270 icons. 272 icons.
*/ */
export const BUNDLED_ICONS = { export const BUNDLED_ICONS = {
"la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32}, "la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32},
@ -110,6 +110,8 @@ export const BUNDLED_ICONS = {
"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:ruler-vertical": {"body":"<path fill=\"currentColor\" d=\"M8 0v32h16V0zm2 2h12v3h-7v2h7v2h-4v2h4v2h-7v2h7v2h-4v2h4v2h-7v2h7v2h-4v2h4v3H10z\"/>","width":32,"height":32}, "la:ruler-vertical": {"body":"<path fill=\"currentColor\" d=\"M8 0v32h16V0zm2 2h12v3h-7v2h7v2h-4v2h4v2h-7v2h7v2h-4v2h4v2h-7v2h7v2h-4v2h4v3H10z\"/>","width":32,"height":32},
"la:search": {"body":"<path fill=\"currentColor\" d=\"M19 3C13.488 3 9 7.488 9 13c0 2.395.84 4.59 2.25 6.313L3.281 27.28l1.439 1.44l7.968-7.969A9.92 9.92 0 0 0 19 23c5.512 0 10-4.488 10-10S24.512 3 19 3m0 2c4.43 0 8 3.57 8 8s-3.57 8-8 8s-8-3.57-8-8s3.57-8 8-8\"/>","width":32,"height":32}, "la:search": {"body":"<path fill=\"currentColor\" d=\"M19 3C13.488 3 9 7.488 9 13c0 2.395.84 4.59 2.25 6.313L3.281 27.28l1.439 1.44l7.968-7.969A9.92 9.92 0 0 0 19 23c5.512 0 10-4.488 10-10S24.512 3 19 3m0 2c4.43 0 8 3.57 8 8s-3.57 8-8 8s-8-3.57-8-8s3.57-8 8-8\"/>","width":32,"height":32},
"la:search-minus": {"body":"<path fill=\"currentColor\" d=\"M19 3C13.488 3 9 7.488 9 13c0 2.395.84 4.59 2.25 6.313L3.281 27.28l1.439 1.44l7.968-7.969A9.92 9.92 0 0 0 19 23c5.512 0 10-4.488 10-10S24.512 3 19 3m0 2c4.43 0 8 3.57 8 8s-3.57 8-8 8s-8-3.57-8-8s3.57-8 8-8m-4 7v2h8v-2z\"/>","width":32,"height":32},
"la:search-plus": {"body":"<path fill=\"currentColor\" d=\"M19 3C13.488 3 9 7.488 9 13c0 2.395.84 4.59 2.25 6.313L3.281 27.28l1.439 1.44l7.968-7.969A9.92 9.92 0 0 0 19 23c5.512 0 10-4.488 10-10S24.512 3 19 3m0 2c4.43 0 8 3.57 8 8s-3.57 8-8 8s-8-3.57-8-8s3.57-8 8-8m-1 4v3h-3v2h3v3h2v-3h3v-2h-3V9z\"/>","width":32,"height":32},
"la:server": {"body":"<path fill=\"currentColor\" d=\"M3 6v20h26V6zm2 2h22v4H5zm2 1v2h8V9zm17 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1M5 14h22v4H5zm2 1v2h8v-2zm17 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1M5 20h22v4H5zm2 1v2h8v-2zm17 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":32,"height":32}, "la:server": {"body":"<path fill=\"currentColor\" d=\"M3 6v20h26V6zm2 2h22v4H5zm2 1v2h8V9zm17 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1M5 14h22v4H5zm2 1v2h8v-2zm17 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1M5 20h22v4H5zm2 1v2h8v-2zm17 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":32,"height":32},
"la:share": {"body":"<path fill=\"currentColor\" d=\"M19.719 5.281L18.28 6.72L24.563 13H11c-3.855 0-7 3.145-7 7s3.145 7 7 7v-2c-2.773 0-5-2.227-5-5s2.227-5 5-5h13.563l-6.282 6.281l1.438 1.438l8-8l.687-.719l-.687-.719z\"/>","width":32,"height":32}, "la:share": {"body":"<path fill=\"currentColor\" d=\"M19.719 5.281L18.28 6.72L24.563 13H11c-3.855 0-7 3.145-7 7s3.145 7 7 7v-2c-2.773 0-5-2.227-5-5s2.227-5 5-5h13.563l-6.282 6.281l1.438 1.438l8-8l.687-.719l-.687-.719z\"/>","width":32,"height":32},
"la:sign-in-alt": {"body":"<path fill=\"currentColor\" d=\"M16 4C10.422 4 5.742 7.832 4.406 13H6.47C7.746 8.945 11.53 6 16 6c5.516 0 10 4.484 10 10s-4.484 10-10 10c-4.469 0-8.254-2.945-9.531-7H4.406c1.336 5.168 6.016 9 11.594 9c6.617 0 12-5.383 12-12S22.617 4 16 4m-.656 7.281l-1.438 1.438L16.187 15H4v2h12.188l-2.282 2.281l1.438 1.438l4-4L20.03 16l-.687-.719z\"/>","width":32,"height":32}, "la:sign-in-alt": {"body":"<path fill=\"currentColor\" d=\"M16 4C10.422 4 5.742 7.832 4.406 13H6.47C7.746 8.945 11.53 6 16 6c5.516 0 10 4.484 10 10s-4.484 10-10 10c-4.469 0-8.254-2.945-9.531-7H4.406c1.336 5.168 6.016 9 11.594 9c6.617 0 12-5.383 12-12S22.617 4 16 4m-.656 7.281l-1.438 1.438L16.187 15H4v2h12.188l-2.282 2.281l1.438 1.438l4-4L20.03 16l-.687-.719z\"/>","width":32,"height":32},

@ -0,0 +1,244 @@
<template>
<w-dialog
class="asset-preview"
v-model="dialogVisible"
full-width
full-height
@hide="onDialogHide">
<div class="asset-preview-frame">
<div class="card-header asset-preview-bar px-4 py-2">
<w-icon name="la:image" left size="md" />
<div class="min-w-0">
<div class="truncate">{{ fileName }}</div>
<div class="text-caption">{{ caption }}</div>
</div>
<w-space />
<w-btn
class="mr-4"
v-if="state.canZoom"
:icon="state.zoomed ? `la:search-minus` : `la:search-plus`"
:aria-label="zoomLabel"
color="teal-3"
dense
flat
@click="toggleZoom">
<w-tooltip anchor="bottom middle" self="top middle">{{ zoomLabel }}</w-tooltip>
</w-btn>
<w-btn
icon="la:times"
:aria-label="t(`common.actions.close`)"
color="pink-2"
dense
flat
@click="onDialogCancel">
<w-tooltip anchor="bottom middle" self="top middle">{{
t(`common.actions.close`)
}}</w-tooltip>
</w-btn>
</div>
<!--
Clicking beside the image dismisses, the way a lightbox does -- `.self` so that only the empty
space around it counts, since the image itself is the thing being looked at.
-->
<div
class="asset-preview-stage"
:class="[state.zoomed ? `is-zoomed` : `is-fitted`, state.canZoom && `can-zoom`]"
@click.self="onDialogCancel">
<w-spinner v-if="state.status === `loading`" size="32px" color="grey-5" />
<div class="asset-preview-failed" v-else-if="state.status === `failed`">
<w-icon name="la:exclamation-triangle" size="lg" class="mb-2" />
<span>{{ t('fileman.previewFailed') }}</span>
</div>
<!--
Kept mounted while it loads rather than rendered on arrival: `@load` is what moves the state
on, and an `<img>` that is not in the document never fires it. `v-show` hides the half-drawn
image without taking it out of the page.
-->
<img
v-show="state.status === `loaded`"
:src="src"
:alt="fileName"
@load="onLoad"
@error="state.status = `failed`"
@click="toggleZoom" />
</div>
</div>
</w-dialog>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { computed, nextTick, reactive } from 'vue'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { assetContentUrl } from '@/helpers/assets'
import { useSiteStore } from '@/stores/site'
/**
* Full-view image viewer.
*
* Opened from the file manager, over it rather than instead of it: closing this comes back to the
* folder that was being browsed, with the same file still selected.
*
* The image is fetched by ID rather than by path -- the same way the thumbnail beside it is. A path
* addresses the primary locale's file of that name, which is a different file from the one being
* looked at whenever the manager is browsing another locale.
*/
// PROPS
const props = defineProps({
assetId: {
type: String,
required: true
},
/** What to call it in the title bar. */
fileName: {
type: String,
required: true
}
})
// EMITS
defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogCancel } = useDialogComponent()
// STORES
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
status: 'loading',
/** Whether the image is bigger than the space it is being shown in, so 1:1 shows more of it. */
canZoom: false,
zoomed: false,
width: 0,
height: 0
})
// COMPUTED
const src = computed(() => assetContentUrl(siteStore.id, props.assetId))
/** The image's own size, once it is known. Read off the loaded image rather than from the asset's
* stored metadata, which an image uploaded before the dimensions were recorded does not have. */
const caption = computed(() =>
state.status === 'loaded' ? `${state.width} × ${state.height}` : ''
)
const zoomLabel = computed(() =>
t(state.zoomed ? 'fileman.previewFitToScreen' : 'fileman.previewActualSize')
)
// METHODS
async function onLoad(ev) {
const img = ev.target
state.width = img.naturalWidth
state.height = img.naturalHeight
state.status = 'loaded'
/*
Whether the fit shrank it, asked of the image itself once it is on screen -- so it accounts for
the stage's padding and for either axis being the one that ran out, neither of which comparing
against the stage's own box would. It has to wait a tick: the image is hidden until the line
above lands, and an element with `display: none` measures zero.
Measured once. A window resized while the viewer is open can leave the button out of step with
the fit, which is worth less than a listener on every resize -- the answer only changes at the
moment the image stops fitting.
*/
await nextTick()
state.canZoom = img.naturalWidth > img.clientWidth || img.naturalHeight > img.clientHeight
}
function toggleZoom() {
if (state.canZoom) {
state.zoomed = !state.zoomed
}
}
</script>
<style scoped lang="scss">
.asset-preview {
&-frame {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
background-color: $dark-6;
}
&-bar {
flex: 0 0 auto;
}
/*
Two modes, and the difference is only what the image is allowed to be: fitted, it is bounded by
the stage and centred in it; at actual size it takes its own dimensions and the stage scrolls,
with `margin: auto` keeping it centred while it is still smaller than the frame in one axis.
*/
&-stage {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
padding: 16px;
&.is-fitted {
overflow: hidden;
img {
max-width: 100%;
max-height: 100%;
}
}
/*
Three declarations to undo three defaults, all of which exist to stop an image overflowing --
which at actual size is the entire point. The base stylesheet caps every image at the width of
its container, a flex item shrinks to fit before it is allowed to overflow, and a flex container
that centres an oversized item puts the overflow past its own start edge where no scrollbar can
reach it. `margin: auto` is the fix for the last: auto margins take the free space when there is
any, and collapse to nothing when there is not, so the image is centred while it fits and
scrolls from its top left corner once it does not.
*/
&.is-zoomed {
overflow: auto;
img {
flex: none;
max-width: none;
max-height: none;
margin: auto;
}
}
// -> Only where pressing the image actually does something; one already at its full size does not
&.can-zoom.is-fitted img {
cursor: zoom-in;
}
&.can-zoom.is-zoomed img {
cursor: zoom-out;
}
}
&-failed {
display: flex;
flex-direction: column;
align-items: center;
color: $grey-5;
font-size: 0.9rem;
}
}
</style>

@ -1,140 +0,0 @@
<template>
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
<w-card class="relative" style="min-width: 650px">
<w-card-section class="card-header">
<w-icon name="img:/_assets/icons/fluent-rename.svg" size="sm" class="mr-2" />
<span>{{ t(`fileman.assetRename`) }}</span>
</w-card-section>
<w-form class="py-2" @submit="rename">
<w-item>
<blueprint-icon icon="image" class="self-start" />
<w-item-section>
<w-input
v-model="state.path"
autofocus
outlined
dense
hide-bottom-space
:label="t(`fileman.assetFileName`)"
:hint="t(`fileman.assetFileNameHint`)"
lazy-rules="ondemand"
@keyup:enter="rename" />
</w-item-section>
</w-item>
</w-form>
<w-card-actions class="card-actions">
<w-space />
<w-btn
class="acrylic-btn"
flat
:label="t(`common.actions.cancel`)"
color="grey"
padding="xs md"
@click="onDialogCancel" />
<w-btn
unelevated
:label="t(`common.actions.rename`)"
color="primary"
padding="xs md"
:loading="state.loading > 0"
@click="rename" />
</w-card-actions>
<w-inner-loading :showing="state.loading > 0" size="38px" spinner-class="text-accent" />
</w-card>
</w-dialog>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { onMounted, reactive } from 'vue'
import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS
const props = defineProps({
assetId: {
type: String,
required: true
}
})
// EMITS
defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
// STORES
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
path: '',
loading: false
})
// METHODS
async function rename() {
state.loading++
try {
if (state.path?.length < 2 || !state.path?.includes('.')) {
throw new Error(t('fileman.renameAssetInvalid'))
}
const resp = await API_CLIENT.patch(`sites/${siteStore.id}/assets/${props.assetId}`, {
json: {
fileName: state.path
}
}).json()
// -> The API client does not throw on 400, so a refused name comes back as a parsed error
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
notify({
type: 'positive',
message: t('fileman.renameAssetSuccess')
})
onDialogOK()
} catch (err) {
// -> ky throws above 400 a name already taken in this folder answers 409
notify({
type: 'negative',
message: apiErrorMessage(err)
})
}
state.loading--
}
// MOUNTED
onMounted(async () => {
state.loading++
try {
const asset = await API_CLIENT.get(`sites/${siteStore.id}/assets/${props.assetId}`).json()
if (asset?.id !== props.assetId) {
throw new Error('Failed to fetch asset data.')
}
state.path = asset.fileName
} catch (err) {
notify({
type: 'negative',
message: apiErrorMessage(err)
})
onDialogCancel()
}
state.loading--
})
</script>

@ -124,12 +124,21 @@
<w-scroll-area :thumb-style="thumbStyle" :bar-style="barStyle" style="height: 100%"> <w-scroll-area :thumb-style="thumbStyle" :bar-style="barStyle" style="height: 100%">
<div class="p-4"> <div class="p-4">
<template v-if="currentFileDetails"> <template v-if="currentFileDetails">
<!--
A button around the thumbnail, and only for an image: it opens the full view, and the
illustration standing in for a page has nothing behind it to open.
-->
<button
class="fileman-details-thumb w-unstyled mb-4 block w-full"
v-if="currentFileDetails.thumbnail && currentFileDetails.viewable"
:aria-label="t(`common.actions.view`)"
@click="viewCurrentFile">
<img class="w-full rounded object-cover" :src="currentFileDetails.thumbnail" />
</button>
<img <img
class="w-full object-cover rounded mb-4" class="mb-4 w-full rounded object-cover"
v-if="currentFileDetails.thumbnail" v-else-if="currentFileDetails.thumbnail"
:src="currentFileDetails.thumbnail" :src="currentFileDetails.thumbnail" />
width="100%"
:ratio="16 / 10" />
<div <div
class="fileman-details-row" class="fileman-details-row"
v-for="item of currentFileDetails.items" v-for="item of currentFileDetails.items"
@ -364,7 +373,10 @@
@click="selectItem(item)" @click="selectItem(item)"
@dblclick="doubleClickItem(item)"> @dblclick="doubleClickItem(item)">
<w-item-section class="fileman-filelist-icon" avatar> <w-item-section class="fileman-filelist-icon" avatar>
<w-icon :name="item.icon" :size="state.isCompact ? `md` : `xl`" /> <w-icon
:name="item.icon"
:size="state.isCompact ? `md` : `xl`"
:style="item.iconStyle" />
</w-item-section> </w-item-section>
<w-item-section class="fileman-filelist-label"> <w-item-section class="fileman-filelist-label">
<w-item-label>{{ usePathTitle ? item.fileName : item.title }}</w-item-label> <w-item-label>{{ usePathTitle ? item.fileName : item.title }}</w-item-label>
@ -415,7 +427,10 @@
</w-item-section> </w-item-section>
<w-item-section>{{ t(`common.actions.view`) }}</w-item-section> <w-item-section>{{ t(`common.actions.view`) }}</w-item-section>
</w-item> </w-item>
<template v-if="item.type === `asset` && item.imageEdit"> <!-- -> Behind the experimental flag: neither has a handler behind it yet,
so on an ordinary instance they are two rows that do nothing -->
<template
v-if="flagsStore.experimental && item.type === `asset` && item.imageEdit">
<w-item clickable> <w-item clickable>
<w-item-section side> <w-item-section side>
<w-icon name="la:edit" color="orange" /> <w-icon name="la:edit" color="orange" />
@ -441,7 +456,21 @@
</w-item-section> </w-item-section>
<w-item-section>{{ t(`common.actions.download`) }}</w-item-section> <w-item-section>{{ t(`common.actions.download`) }}</w-item-section>
</w-item> </w-item>
<w-item clickable @click="duplicateItem(item)"> <w-item
clickable
v-if="item.type === `folder`"
@click="setFolderColor(item.id)">
<w-item-section side>
<w-icon name="la:fill" color="orange" />
</w-item-section>
<w-item-section>{{ t(`common.actions.setColor`) }}...</w-item-section>
</w-item>
<!--
Not for a file: duplicating is copying content to a second path, and an
upload has none to copy -- a second name over the same bytes is all it could
mean, which is not what anyone is asking a wiki for.
-->
<w-item clickable v-if="item.type !== `asset`" @click="duplicateItem(item)">
<w-item-section side> <w-item-section side>
<w-icon name="la:copy" color="teal" /> <w-icon name="la:copy" color="teal" />
</w-item-section> </w-item-section>
@ -458,18 +487,34 @@
</w-item-section> </w-item-section>
<w-item-section>Rename / Move Page...</w-item-section> <w-item-section>Rename / Move Page...</w-item-section>
</w-item> </w-item>
<!--
One entry for a file too, and for a plainer reason than a page's: its name
and its folder are the two halves of where a storage target keeps it, so
changing either is the same move to everything downstream of the rename.
-->
<w-item
clickable
v-else-if="item.type === `asset`"
@click="renameMoveAsset(item)">
<w-item-section side>
<w-icon name="la:share" color="teal" />
</w-item-section>
<w-item-section>Rename / Move To...</w-item-section>
</w-item>
<!-- -> Folders, which are the only thing left with two entries: their name
and their place are asked for in two different dialogs -->
<template v-else> <template v-else>
<w-item clickable @click="renameItem(item)"> <w-item clickable @click="renameFolder(item.id)">
<w-item-section side> <w-item-section side>
<w-icon name="la:redo" color="teal" /> <w-icon name="la:redo" color="teal" />
</w-item-section> </w-item-section>
<w-item-section>Rename...</w-item-section> <w-item-section>Rename...</w-item-section>
</w-item> </w-item>
<w-item clickable> <w-item clickable @click="moveFolder(item.id)">
<w-item-section side> <w-item-section side>
<w-icon name="la:arrow-right" color="teal" /> <w-icon name="la:arrow-right" color="teal" />
</w-item-section> </w-item-section>
<w-item-section>Move to...</w-item-section> <w-item-section>Move To...</w-item-section>
</w-item> </w-item>
</template> </template>
<w-item clickable @click="delItem(item)"> <w-item clickable @click="delItem(item)">
@ -515,11 +560,13 @@ import {
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { dialog } from '@/composables/dialog' import { dialog } from '@/composables/dialog'
import { loading } from '@/composables/loading'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { useMinWidth, useScreen } from '@/composables/screen' import { useMinWidth, useScreen } from '@/composables/screen'
import { useDark } from '@/composables/dark' import { useDark } from '@/composables/dark'
import { useCommonStore } from '@/stores/common' import { useCommonStore } from '@/stores/common'
import { useFlagsStore } from '@/stores/flags'
import { usePageStore } from '@/stores/page' import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
@ -530,10 +577,10 @@ import Tree from './TreeNav.vue'
import { apiErrorMessage } from '@/helpers/apiError' import { apiErrorMessage } from '@/helpers/apiError'
import { assetUrl } from '@/helpers/assets' import { assetUrl } from '@/helpers/assets'
import fileTypes from '@/helpers/fileTypes' import fileTypes from '@/helpers/fileTypes'
import { folderIconStyle } from '@/helpers/folderColors'
import FolderCreateDialog from '@/components/FolderCreateDialog.vue' import FolderCreateDialog from '@/components/FolderCreateDialog.vue'
import FolderDeleteDialog from '@/components/FolderDeleteDialog.vue' import FolderDeleteDialog from '@/components/FolderDeleteDialog.vue'
import FolderRenameDialog from '@/components/FolderRenameDialog.vue' import FolderRenameDialog from '@/components/FolderRenameDialog.vue'
import AssetRenameDialog from '@/components/AssetRenameDialog.vue'
import LocaleSelectorMenu from '@/components/LocaleSelectorMenu.vue' import LocaleSelectorMenu from '@/components/LocaleSelectorMenu.vue'
// COMPOSABLES // COMPOSABLES
@ -544,6 +591,7 @@ const screen = useScreen()
// STORES // STORES
const commonStore = useCommonStore() const commonStore = useCommonStore()
const flagsStore = useFlagsStore()
const pageStore = usePageStore() const pageStore = usePageStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
@ -696,14 +744,16 @@ const treeDrawerOpen = computed({
}) })
const folderPath = computed(() => { const folderPath = computed(() => {
if (!state.currentFolderId) { // -> The path comes out of the tree cache, so a selected folder that is not in it -- one whose
// branch was just reloaded, or which has moved out of the locale being listed -- has no path to
// show rather than `/undefined/`, which is what it used to render
const folderNode = state.currentFolderId ? state.treeNodes[state.currentFolderId] : null
if (!folderNode?.fileName) {
return '/' return '/'
} else {
const folderNode = state.treeNodes[state.currentFolderId] ?? {}
return folderNode.folderPath
? `/${folderNode.folderPath}/${folderNode.fileName}/`
: `/${folderNode.fileName}/`
} }
return folderNode.folderPath
? `/${folderNode.folderPath}/${folderNode.fileName}/`
: `/${folderNode.fileName}/`
}) })
const usePathTitle = computed(() => state.displayMode === 'path') const usePathTitle = computed(() => state.displayMode === 'path')
@ -732,6 +782,7 @@ const files = computed(() => {
switch (f.type) { switch (f.type) {
case 'folder': { case 'folder': {
f.icon = fileTypes.folder.icon f.icon = fileTypes.folder.icon
f.iconStyle = folderIconStyle(f.hue)
f.caption = t('fileman.folderChildrenCount', { count: f.children }, f.children) f.caption = t('fileman.folderChildrenCount', { count: f.children }, f.children)
break break
} }
@ -757,6 +808,14 @@ const files = computed(() => {
}) })
}) })
/**
* Whether this listing entry is an image, which is what decides both the thumbnail beside it and
* whether View opens it in place rather than handing it to the browser.
*/
function isImageAsset(item) {
return item.type === 'asset' && Boolean(item.mimeType?.startsWith('image/'))
}
const currentFileDetails = computed(() => { const currentFileDetails = computed(() => {
if (!state.currentFileId) { if (!state.currentFileId) {
return null return null
@ -796,7 +855,7 @@ const currentFileDetails = computed(() => {
} }
case 'asset': { case 'asset': {
// -> Only images get one, and the endpoint answers 404 for anything else // -> Only images get one, and the endpoint answers 404 for anything else
thumbnail = item.mimeType?.startsWith('image/') ? `/_thumb/${item.id}.webp` : null thumbnail = isImageAsset(item) ? `/_thumb/${item.id}.webp` : null
items.push({ items.push({
label: t('fileman.detailsAssetType'), label: t('fileman.detailsAssetType'),
value: fileTypes[item.fileExt] value: fileTypes[item.fileExt]
@ -807,11 +866,21 @@ const currentFileDetails = computed(() => {
label: t('fileman.detailsAssetSize'), label: t('fileman.detailsAssetSize'),
value: filesize(item.fileSize) value: filesize(item.fileSize)
}) })
// -> Only an image has these, and only one measured on arrival: an upload from before the Sharp
// extension was installed carries none, so the row is left out rather than shown empty
if (item.width && item.height) {
items.push({
label: t('fileman.detailsAssetDimensions'),
value: `${item.width} × ${item.height}`
})
}
break break
} }
} }
return { return {
thumbnail, thumbnail,
// -> Whether the thumbnail leads anywhere: only an image has a full view to open behind it
viewable: item.type === 'asset' && isImageAsset(item),
items items
} }
}) })
@ -910,6 +979,7 @@ async function loadTree({ parentId = null, parentPath = null, types, initLoad =
folderPath: item.folderPath, folderPath: item.folderPath,
fileName: item.fileName, fileName: item.fileName,
title: item.title, title: item.title,
hue: item.hue,
children: state.treeNodes[item.id]?.children ?? [] children: state.treeNodes[item.id]?.children ?? []
} }
@ -942,6 +1012,7 @@ async function loadTree({ parentId = null, parentPath = null, types, initLoad =
type: 'folder', type: 'folder',
title: item.title, title: item.title,
fileName: item.fileName, fileName: item.fileName,
hue: item.hue,
children: item.childrenCount || 0 children: item.childrenCount || 0
}) })
} }
@ -956,6 +1027,8 @@ async function loadTree({ parentId = null, parentPath = null, types, initLoad =
fileExt: item.fileExt, fileExt: item.fileExt,
fileSize: item.fileSize, fileSize: item.fileSize,
mimeType: item.mimeType, mimeType: item.mimeType,
width: item.width,
height: item.height,
folderPath: item.folderPath, folderPath: item.folderPath,
fileName: item.fileName, fileName: item.fileName,
createdAt: item.createdAt, createdAt: item.createdAt,
@ -1036,6 +1109,18 @@ function treeContextAction(nodeId, action) {
renameFolder(nodeId) renameFolder(nodeId)
break break
} }
case 'move': {
moveFolder(nodeId)
break
}
case 'color': {
setFolderColor(nodeId)
break
}
case 'duplicate': {
duplicateFolder(nodeId)
break
}
case 'del': { case 'del': {
delFolder(nodeId) delFolder(nodeId)
break break
@ -1082,6 +1167,145 @@ function renameFolder(folderId) {
}) })
} }
/**
* Move a folder, and everything under it, to another parent or another locale.
*
* The view follows it: a move is not something to be left looking at the hole it left, and the folder
* is where the person who moved it now wants to be.
*/
function moveFolder(folderId) {
const node = state.treeNodes[folderId]
dialog({
component: defineAsyncComponent(() => import('@/components/TreeBrowserDialog.vue')),
componentProps: {
mode: 'moveFolder',
itemId: folderId,
itemTitle: node?.title ?? '',
folderPath: node?.folderPath ?? '',
itemFileName: node?.fileName ?? '',
locale: state.locale
}
}).onOk(async (opts) => {
/*
Blocking, as a copy is: the rows move in one statement, but the copy every storage target holds
moves one page and one file at a time -- which on a target that is a repository or a bucket is a
write apiece. Same reasoning, same overlay.
*/
loading.show({
message: t('fileman.folderMoving'),
caption: t('fileman.folderMovingHint')
})
try {
const resp = await API_CLIENT.put(`sites/${siteStore.id}/tree/folders/${folderId}/path`, {
json: {
folderPath: opts.folderPath,
locale: opts.locale
},
// -> Nothing on this side gives up on it, since the server does not either
timeout: false
}).json()
// -> The API client does not throw on 400, so a refused destination comes back as a parsed error
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
notify({
type: 'positive',
message: resp.message
})
await goToFolder(resp.folder)
} catch (err) {
notify({
type: 'negative',
message: 'Failed to move folder.',
caption: apiErrorMessage(err, 'An unexpected error occured.')
})
} finally {
// -> In `finally` because `goToFolder` above can throw too, and an overlay left up is a wiki
// nobody can click on
loading.hide()
}
})
}
/**
* Colour a folder's icon.
*
* Only the two rows that hold it need repainting, so nothing is refetched: the tree node and the
* listing row are the same folder seen twice, and the icon in each reads its colour off them.
*/
function setFolderColor(folderId) {
const node = state.treeNodes[folderId]
dialog({
component: defineAsyncComponent(() => import('@/components/FolderColorDialog.vue')),
componentProps: {
folderId,
folderTitle: node?.title ?? '',
hue: node?.hue ?? 0
}
}).onOk((hue) => {
if (node) {
node.hue = hue
}
const row = state.fileList.find((f) => f.id === folderId)
if (row) {
row.hue = hue
}
})
}
/** The id of an already-loaded folder, addressed the way a path addresses it. */
function findFolderIdByPath(path) {
if (!path) {
return null
}
const entry = Object.entries(state.treeNodes).find(
([, node]) => (node.folderPath ? `${node.folderPath}/${node.fileName}` : node.fileName) === path
)
return entry?.[0] ?? null
}
/**
* Open a folder wherever it now is, rebuilding the tree around it.
*
* Everything cached is dropped rather than patched: a move rewrites the path of every entry under the
* folder, so the branch it left and the branch it joined are both wrong, and across locales the whole
* tree on screen belongs to a locale the folder is no longer in.
*
* @param folder The folder as the API answered with it where it is now, and which locale it is in.
*/
async function goToFolder({ id, folderPath, fileName, locale }) {
const path = folderPath ? `${folderPath}/${fileName}` : fileName
// -> Set before anything is fetched: every request below asks for one locale's tree
state.locale = locale
state.treeNodes = {}
state.treeRoots = []
state.currentFileId = null
state.fileList = []
treeComp.value?.resetLoaded()
// -> With its ancestors, which is the whole branch the tree has to draw to show where it landed
const wasCurrent = state.currentFolderId === id
await loadTree({ parentPath: path, initLoad: true })
const parts = path.split('/')
for (let i = 1; i <= parts.length; i++) {
const ancestorId = findFolderIdByPath(parts.slice(0, i).join('/'))
if (ancestorId) {
treeComp.value?.setOpened(ancestorId)
}
}
/*
Selecting it is what lists its contents, through the watcher on this -- but only when the value
actually changes, and moving the folder somebody was already inside does not change it. Hence the
second call, which is that same load done by hand. Both come after the await above, so neither
can be dropped by `loadTree`'s guard against overlapping fetches.
*/
state.currentFolderId = id
if (wasCurrent) {
await loadTree({ parentId: id })
}
}
function delFolder(folderId, mustReload = false) { function delFolder(folderId, mustReload = false) {
dialog({ dialog({
component: FolderDeleteDialog, component: FolderDeleteDialog,
@ -1233,15 +1457,49 @@ function delPage(pageId, pageName) {
// ASSET METHODS // ASSET METHODS
// -------------------------------------- // --------------------------------------
function renameAsset(assetId) { /**
* Rename a file, move it to another folder, or both one dialog and one request, as it is for a
* page.
*/
function renameMoveAsset(item) {
dialog({ dialog({
component: AssetRenameDialog, component: defineAsyncComponent(() => import('@/components/TreeBrowserDialog.vue')),
componentProps: { componentProps: {
assetId mode: 'renameAsset',
itemId: item.id,
itemTitle: item.title,
folderPath: item.folderPath,
itemFileName: item.fileName,
locale: state.locale
}
}).onOk(async (opts) => {
try {
const resp = await API_CLIENT.patch(`sites/${siteStore.id}/assets/${item.id}`, {
json: {
fileName: opts.fileName,
folderPath: opts.folderPath,
// -> The destination is a locale as well as a folder, as it is for a page: the same folder
// in another locale is somewhere else
locale: opts.locale
}
}).json()
// -> The API client does not throw on 400, so a refused name comes back as a parsed error
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
notify({
type: 'positive',
message: resp.message
})
// -> Reload current view
await loadTree({ parentId: state.currentFolderId })
} catch (err) {
notify({
type: 'negative',
message: 'Failed to rename or move file.',
caption: apiErrorMessage(err, 'An unexpected error occured.')
})
} }
}).onOk(async () => {
// -> Reload current view
await loadTree({ parentId: state.currentFolderId })
}) })
} }
@ -1381,14 +1639,44 @@ function openItem(item) {
close() close()
break break
} }
/*
The manager stays open behind both of these, unlike a page: the viewer is a look at one file
rather than a departure from the folder, and closing it comes back to the same selection.
*/
case 'asset': { case 'asset': {
// TODO: Open asset if (isImageAsset(item)) {
close() viewImage(item)
} else {
// -> Nothing here can display it, so the browser is given the chance: it opens what it knows
// -- a PDF, a video -- and downloads the rest, which is what View means for those
window.open(assetUrl(item.folderPath, item.fileName), '_blank', 'noopener')
}
break break
} }
} }
} }
/**
* Open an image at full size, over the manager.
*/
function viewImage(item) {
dialog({
component: defineAsyncComponent(() => import('@/components/AssetPreviewDialog.vue')),
componentProps: {
assetId: item.id,
fileName: item.fileName
}
})
}
/** The details pane's thumbnail, which is only offered for a file `viewImage` can show. */
function viewCurrentFile() {
const item = state.fileList.find((f) => f.id === state.currentFileId)
if (item && isImageAsset(item)) {
viewImage(item)
}
}
async function copyItemURL(item) { async function copyItemURL(item) {
try { try {
switch (item.type) { switch (item.type) {
@ -1449,34 +1737,83 @@ async function downloadItem(item) {
} }
} }
function renameItem(item) { function duplicateItem(item) {
switch (item.type) { switch (item.type) {
case 'folder': {
renameFolder(item.id)
break
}
case 'page': { case 'page': {
renameMovePage(item) duplicatePage(item)
break break
} }
case 'asset': { case 'folder': {
renameAsset(item.id) duplicateFolder(item.id)
break break
} }
} }
} }
/** /**
* Duplicating a folder or an asset has no endpoint behind it yet, so those two keep the inert entry * Copy a folder, and everything under it, somewhere else under a name of its own.
* they already had rather than being offered something that would fail. *
* The view goes to the copy rather than staying where it was, as a move does: what was asked for is a
* new folder, and it is the new folder somebody wants to look at.
*/ */
function duplicateItem(item) { function duplicateFolder(folderId) {
switch (item.type) { const node = state.treeNodes[folderId]
case 'page': { dialog({
duplicatePage(item) component: defineAsyncComponent(() => import('@/components/TreeBrowserDialog.vue')),
break componentProps: {
mode: 'duplicateFolder',
itemId: folderId,
itemTitle: node?.title ?? '',
folderPath: node?.folderPath ?? '',
itemFileName: node?.fileName ?? '',
locale: state.locale
} }
} }).onOk(async (opts) => {
/*
Blocking, and with a word about why: the copy is one request that runs to completion on the
server -- a page rendered, indexed and written out per entry -- so a folder of any size is
seconds rather than milliseconds, and the manager would otherwise sit there looking idle while
it happened, with nothing to stop somebody starting a second one on top of it.
*/
loading.show({
message: t('fileman.folderDuplicating'),
caption: t('fileman.folderDuplicatingHint')
})
try {
const resp = await API_CLIENT.post(
`sites/${siteStore.id}/tree/folders/${folderId}/duplicate`,
{
json: {
folderPath: opts.folderPath,
pathName: opts.pathName,
title: opts.title,
locale: opts.locale
},
// -> Nothing on this side gives up on it, since the server does not either
timeout: false
}
).json()
// -> The API client does not throw on 400, so a refused name comes back as a parsed error
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
notify({
type: 'positive',
message: resp.message
})
await goToFolder(resp.folder)
} catch (err) {
notify({
type: 'negative',
message: 'Failed to duplicate folder.',
caption: apiErrorMessage(err, 'An unexpected error occured.')
})
} finally {
// -> In `finally` because `goToFolder` above can throw too, and an overlay left up is a wiki
// nobody can click on
loading.hide()
}
})
} }
function delItem(item) { function delItem(item) {
@ -1811,6 +2148,22 @@ $fileman-hdr-wrap-max: 899.98px;
} }
} }
} }
/*
The thumbnail reads as something to press: it is the way into the full view, and a picture with no
affordance on it is one nobody clicks. `zoom-in` rather than `pointer` says which way it leads.
*/
&-details-thumb {
cursor: zoom-in;
img {
transition: box-shadow 0.2s var(--ease-standard);
}
&:hover img,
&:focus-visible img {
box-shadow: 0 0 0 2px var(--color-primary);
}
}
&-details-row { &-details-row {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

@ -0,0 +1,177 @@
<template>
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
<w-card class="folder-color relative" style="width: 420px; max-width: 90vw">
<w-card-section class="card-header">
<w-icon name="img:/_assets/icons/fluent-color-wheel.svg" size="sm" class="mr-2" />
<div class="min-w-0">
<div>{{ t('fileman.folderColor') }}</div>
<div class="text-caption truncate">{{ folderTitle }}</div>
</div>
</w-card-section>
<!--
The swatch is the folder icon itself under the filter it is offering, rather than a plain
square of colour: the filter is an approximation over RGB and what it makes of the icon is not
quite the colour the angle names, so a square would be promising something slightly different
from what the tree will show.
-->
<div class="folder-color-grid p-4">
<button
v-for="color of FOLDER_COLORS"
:key="color.hue"
class="folder-color-swatch w-unstyled"
:class="color.hue === state.hue && `is-selected`"
:aria-label="color.name"
:aria-pressed="color.hue === state.hue"
@click="state.hue = color.hue">
<w-icon
name="img:/_assets/icons/fluent-folder.svg"
size="lg"
:style="folderIconStyle(color.hue)" />
<w-tooltip>{{ color.name }}</w-tooltip>
</button>
</div>
<w-card-actions class="card-actions">
<w-space />
<w-btn
class="acrylic-btn"
flat
:label="t(`common.actions.cancel`)"
color="grey"
padding="xs md"
@click="onDialogCancel" />
<w-btn
unelevated
:label="t(`common.actions.apply`)"
color="primary"
padding="xs md"
:loading="state.loading > 0"
@click="save" />
</w-card-actions>
<w-inner-loading :showing="state.loading > 0" size="38px" spinner-class="text-accent" />
</w-card>
</w-dialog>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { reactive } from 'vue'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { FOLDER_COLORS, folderIconStyle } from '@/helpers/folderColors'
import { apiErrorMessage } from '@/helpers/apiError'
import { useSiteStore } from '@/stores/site'
/**
* Pick the colour of a folder's icon.
*
* What is chosen is a hue rotation applied to the one folder icon the app draws everywhere, not a
* colour of its own see `helpers/folderColors`. The first swatch is a rotation of nothing, which is
* both the colour every folder starts out and the way to put a coloured one back.
*/
// PROPS
const props = defineProps({
folderId: {
type: String,
required: true
},
folderTitle: {
type: String,
default: ''
},
/** The folder's current colour, so the dialog opens on it. */
hue: {
type: Number,
default: 0
}
})
// EMITS
defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
// STORES
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
hue: props.hue,
loading: 0
})
// METHODS
async function save() {
state.loading++
try {
const resp = await API_CLIENT.put(
`sites/${siteStore.id}/tree/folders/${props.folderId}/color`,
{
json: { hue: state.hue }
}
).json()
// -> The API client does not throw on 400, so a refused value comes back as a parsed error
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
onDialogOK(state.hue)
} catch (err) {
notify({
type: 'negative',
message: 'Failed to set the folder color.',
caption: apiErrorMessage(err, 'An unexpected error occured.')
})
}
state.loading--
}
</script>
<style scoped lang="scss">
.folder-color {
&-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
&-swatch {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 0;
border: 2px solid transparent;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s var(--ease-standard);
&:hover {
@at-root .body--light & {
background-color: $blue-grey-1;
}
@at-root .body--dark & {
background-color: $dark-4;
}
}
// -> The border rather than a background: the swatch IS a colour, and tinting the box behind it
// would be one more colour arguing with it
&.is-selected,
&:focus-visible {
border-color: var(--color-primary);
}
}
}
</style>

@ -66,7 +66,7 @@
:active="item.type === `page` && item.path === state.path" :active="item.type === `page` && item.path === state.path"
@click="selectItem(item)"> @click="selectItem(item)">
<w-item-section side> <w-item-section side>
<w-icon :name="item.icon" size="sm" /> <w-icon :name="item.icon" size="sm" :style="item.iconStyle" />
</w-item-section> </w-item-section>
<w-item-section> <w-item-section>
<w-item-label>{{ item.title }}</w-item-label> <w-item-label>{{ item.title }}</w-item-label>
@ -149,6 +149,7 @@ import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError' import { apiErrorMessage } from '@/helpers/apiError'
import fileTypes from '@/helpers/fileTypes' import fileTypes from '@/helpers/fileTypes'
import { folderIconStyle } from '@/helpers/folderColors'
import { splitLocalePath } from '@/helpers/pagePaths' import { splitLocalePath } from '@/helpers/pagePaths'
import LocaleSelectorMenu from '@/components/LocaleSelectorMenu.vue' import LocaleSelectorMenu from '@/components/LocaleSelectorMenu.vue'
@ -343,6 +344,7 @@ async function loadTree({ parentId = null, parentPath = null, initLoad = false }
folderPath: entry.folderPath, folderPath: entry.folderPath,
fileName: entry.fileName, fileName: entry.fileName,
title: entry.title, title: entry.title,
hue: entry.hue,
children: state.treeNodes[entry.id]?.children ?? [] children: state.treeNodes[entry.id]?.children ?? []
} }
if (entry.folderPath) { if (entry.folderPath) {
@ -364,7 +366,8 @@ async function loadTree({ parentId = null, parentPath = null, initLoad = false }
type: entry.type, type: entry.type,
title: entry.title, title: entry.title,
path, path,
icon: entry.type === 'folder' ? fileTypes.folder.icon : fileTypes.page.icon icon: entry.type === 'folder' ? fileTypes.folder.icon : fileTypes.page.icon,
iconStyle: entry.type === 'folder' ? folderIconStyle(entry.hue) : undefined
}) })
} }
} }

@ -7,7 +7,12 @@
--> -->
<w-card-section class="card-header"> <w-card-section class="card-header">
<w-icon :name="header.icon" size="sm" class="mr-2" /> <w-icon :name="header.icon" size="sm" class="mr-2" />
<span>{{ t(header.title) }}</span> <div class="min-w-0">
<div>{{ t(header.title) }}</div>
<!-- -> Which folder is being moved: the form below is a destination and nothing else, so
without this the dialog never says what it is acting on -->
<div class="text-caption truncate" v-if="isFolderMode">{{ itemTitle }}</div>
</div>
<w-space /> <w-space />
<!-- -> Only where there is a choice to make: one active locale is most wikis, and a button <!-- -> Only where there is a choice to make: one active locale is most wikis, and a button
that can only say `en` is noise on all of them --> that can only say `en` is noise on all of them -->
@ -20,7 +25,9 @@
color="white" color="white"
:label="siteStore.localeAlias(state.locale)" :label="siteStore.localeAlias(state.locale)"
:aria-label="siteStore.localeAlias(state.locale)"> :aria-label="siteStore.localeAlias(state.locale)">
<w-tooltip>{{ t(`pageSaveDialog.localeHint`) }}</w-tooltip> <w-tooltip>{{
t(isAssetMode ? `fileman.assetLocaleHint` : `pageSaveDialog.localeHint`)
}}</w-tooltip>
<locale-selector-menu <locale-selector-menu
:selected="state.locale" :selected="state.locale"
:navigate="false" :navigate="false"
@ -64,7 +71,7 @@
:active="item.id === state.currentFileId" :active="item.id === state.currentFileId"
@click="selectItem(item)"> @click="selectItem(item)">
<w-item-section side> <w-item-section side>
<w-icon :name="item.icon" size="sm" /> <w-icon :name="item.icon" size="sm" :style="item.iconStyle" />
</w-item-section> </w-item-section>
<w-item-section> <w-item-section>
<w-item-label>{{ item.title }}</w-item-label> <w-item-label>{{ item.title }}</w-item-label>
@ -75,8 +82,30 @@
</div> </div>
</div> </div>
<div class="page-save-dialog-path font-robotomono">{{ currentFolderPath }}</div> <div class="page-save-dialog-path font-robotomono">{{ currentFolderPath }}</div>
<w-list class="py-2"> <w-list class="py-2" v-if="!isFolderMode">
<w-item> <!--
A folder is named twice over -- what it is called, and the segment its children's paths are
built from -- so a copy asks for both, the way the create and rename dialogs do. The path
follows the title until that field is touched, which is what makes `Guides copy` arrive as
`guides-copy` without anybody typing it.
-->
<w-item v-if="isFolderCopyMode">
<blueprint-icon icon="folder" />
<w-item-section>
<w-input
v-model="state.title"
:label="t(`fileman.folderTitle`)"
dense
outlined
autofocus
@keyup:enter="save" />
</w-item-section>
</w-item>
<!--
A page has a title and, separately, the path segment it is addressed by; a file has one
name that is both, so the asset mode asks for that alone.
-->
<w-item v-if="!isAssetMode && !isFolderCopyMode">
<blueprint-icon icon="new-document" /> <blueprint-icon icon="new-document" />
<w-item-section> <w-item-section>
<w-input <w-input
@ -90,13 +119,15 @@
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-item> <w-item>
<blueprint-icon icon="file-submodule" /> <blueprint-icon :icon="isAssetMode ? `image` : `file-submodule`" />
<w-item-section> <w-item-section>
<w-input <w-input
v-model="state.path" v-model="state.path"
:label="t(`pageSaveDialog.pathName`)" :label="t(pathField.label)"
:hint="pathField.hint ? t(pathField.hint) : undefined"
dense dense
outlined outlined
:autofocus="isAssetMode"
@focus="onPathFocus" @focus="onPathFocus"
@keyup:enter="save" /> @keyup:enter="save" />
</w-item-section> </w-item-section>
@ -165,6 +196,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import slugify from 'slugify' import slugify from 'slugify'
import fileTypes from '../helpers/fileTypes' import fileTypes from '../helpers/fileTypes'
import { folderIconStyle } from '@/helpers/folderColors'
import FolderCreateDialog from '@/components/FolderCreateDialog.vue' import FolderCreateDialog from '@/components/FolderCreateDialog.vue'
import LocaleSelectorMenu from '@/components/LocaleSelectorMenu.vue' import LocaleSelectorMenu from '@/components/LocaleSelectorMenu.vue'
@ -261,12 +293,48 @@ 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' }
} }
case 'renameAsset': {
return { icon: 'img:/_assets/icons/fluent-rename.svg', title: 'fileman.assetRenameMove' }
}
case 'moveFolder': {
return { icon: 'img:/_assets/icons/fluent-folder-tree.svg', title: 'fileman.folderMove' }
}
case 'duplicateFolder': {
return { icon: 'img:/_assets/icons/color-documents.svg', title: 'fileman.folderDuplicate' }
}
default: { default: {
return { icon: 'img:/_assets/icons/fluent-save-as.svg', title: 'pageSaveDialog.title' } return { icon: 'img:/_assets/icons/fluent-save-as.svg', title: 'pageSaveDialog.title' }
} }
} }
}) })
/** Whether this is browsing for somewhere to put a file rather than a page. */
const isAssetMode = computed(() => props.mode === 'renameAsset')
/**
* Whether this is picking a destination and nothing else. A folder keeps its own name and title when
* it moves -- renaming one is its own dialog -- so there is no field to fill in, and the browser is
* the whole of the form.
*/
const isFolderMode = computed(() => props.mode === 'moveFolder')
/**
* Whether a folder is being copied, which asks for a destination AND a name: the copy is a new folder
* and needs one of its own, defaulted to the source's with `copy` on the end.
*/
const isFolderCopyMode = computed(() => props.mode === 'duplicateFolder')
/** What the one field every mode shares is asking for. */
const pathField = computed(() => {
if (isAssetMode.value) {
return { label: 'fileman.assetFileName', hint: 'fileman.assetFileNameHint' }
}
if (isFolderCopyMode.value) {
return { label: 'fileman.folderFileName', hint: 'fileman.folderFileNameHint' }
}
return { label: 'pageSaveDialog.pathName', hint: null }
})
const currentFolderPath = computed(() => { const currentFolderPath = computed(() => {
const folderNode = state.currentFolderId ? state.treeNodes[state.currentFolderId] : null const folderNode = state.currentFolderId ? state.treeNodes[state.currentFolderId] : null
if (!folderNode?.fileName) { if (!folderNode?.fileName) {
@ -282,12 +350,17 @@ const files = computed(() => {
switch (f.type) { switch (f.type) {
case 'folder': { case 'folder': {
f.icon = fileTypes.folder.icon f.icon = fileTypes.folder.icon
f.iconStyle = folderIconStyle(f.hue)
break break
} }
case 'page': { case 'page': {
f.icon = fileTypes.page.icon f.icon = fileTypes.page.icon
break break
} }
case 'asset': {
f.icon = fileTypes[f.fileExt]?.icon ?? ''
break
}
} }
return f return f
}) })
@ -323,6 +396,60 @@ function onPathFocus() {
} }
async function save() { async function save() {
// -> Nothing to validate: the destination is whatever folder the browser is open on, and the site
// root is a real answer
if (isFolderMode.value) {
onDialogOK({
locale: state.locale,
folderPath: currentFolderPath.value.slice(1, -1)
})
return
}
/*
A folder is held to what a folder is held to everywhere else: a title that is shown, and a path
segment that every page underneath is addressed through. The server checks both again, and it is
the one that decides whether the name is free where the copy is going.
*/
if (isFolderCopyMode.value) {
if (!state.title?.trim()) {
notify({ type: 'negative', message: t('fileman.folderTitleMissing') })
return
}
state.path = normalizePagePath(state.path)
if (!/^[a-z0-9-]+$/.test(state.path)) {
notify({ type: 'negative', message: t('fileman.folderFileNameInvalid') })
return
}
onDialogOK({
locale: state.locale,
folderPath: currentFolderPath.value.slice(1, -1),
pathName: state.path,
title: state.title.trim()
})
return
}
/*
A file name is not a slug: it keeps its extension, which is what decides the type the file is
served as, so the page rules below -- which refuse a dot -- cannot be applied to it. Held to what
the rename dialog it replaces asked for, and left to the server to sanitize beyond that; the
stored name comes back on the response.
*/
if (isAssetMode.value) {
const name = state.path?.trim() ?? ''
if (name.length < 2 || !name.includes('.')) {
notify({
type: 'negative',
message: t('fileman.renameAssetInvalid')
})
return
}
onDialogOK({
locale: state.locale,
folderPath: currentFolderPath.value.slice(1, -1),
fileName: name
})
return
}
if (!state.title?.trim()) { if (!state.title?.trim()) {
notify({ notify({
type: 'negative', type: 'negative',
@ -416,6 +543,7 @@ async function loadTree({ parentId = null, parentPath = null, initLoad = false }
folderPath: item.folderPath, folderPath: item.folderPath,
fileName: item.fileName, fileName: item.fileName,
title: item.title, title: item.title,
hue: item.hue,
children: state.treeNodes[item.id]?.children ?? [] children: state.treeNodes[item.id]?.children ?? []
} }
@ -447,7 +575,8 @@ async function loadTree({ parentId = null, parentPath = null, initLoad = false }
id: item.id, id: item.id,
type: 'folder', type: 'folder',
title: item.title, title: item.title,
fileName: item.fileName fileName: item.fileName,
hue: item.hue
}) })
} }
break break
@ -467,6 +596,21 @@ async function loadTree({ parentId = null, parentPath = null, initLoad = false }
} }
break break
} }
// -> Only the asset mode asks for these, and it asks for them so that the folder being
// picked shows the files already in it -- which is where a name clash becomes visible
case 'asset': {
if (isCurrentFolder) {
state.fileList.push({
id: item.id,
type: 'asset',
title: item.title,
fileExt: item.fileExt,
folderPath: item.folderPath,
fileName: item.fileName
})
}
break
}
} }
} }
if (newTreeRoots.length > 0) { if (newTreeRoots.length > 0) {
@ -552,9 +696,35 @@ onMounted(async () => {
state.pathDirty = true state.pathDirty = true
break break
} }
/*
Assets rather than pages in the file list: this is browsing for a folder to put a file in, and
what is worth seeing there is the other files already in it. `pathDirty` because an asset has
no title of its own to derive a name from -- the two are the same string.
*/
case 'renameAsset': {
state.typesToFetch = ['folder', 'asset']
state.pathDirty = true
break
}
// -> Folders alone: the answer being picked is which one to move into, and a listing of the pages
// beside it is not part of that question
case 'moveFolder': {
state.typesToFetch = ['folder']
state.pathDirty = true
break
}
case 'duplicateFolder': {
state.typesToFetch = ['folder']
break
}
} }
state.title = props.itemTitle || '' /*
state.path = fName || '' A copy is offered the source's name with `copy` on the end, which the path field then slugs into
`guides-copy`. Set before the watcher can see it -- it only follows the title while the path has
not been touched, and this is one write, not a typed one.
*/
state.title = isFolderCopyMode.value ? `${props.itemTitle} copy` : props.itemTitle || ''
state.path = isFolderCopyMode.value ? `${fName}-copy` : fName || ''
await loadTree({ await loadTree({
parentPath: fPath, parentPath: fPath,
initLoad: true initLoad: true

@ -32,7 +32,7 @@ const props = defineProps({
}, },
contextActionList: { contextActionList: {
type: Array, type: Array,
default: () => ['newFolder', 'duplicate', 'rename', 'move', 'del'] default: () => ['newFolder', 'color', 'duplicate', 'rename', 'move', 'del']
}, },
displayMode: { displayMode: {
type: String, type: String,
@ -56,6 +56,11 @@ const contextActions = {
iconColor: 'blue', iconColor: 'blue',
label: t('common.actions.newFolder') label: t('common.actions.newFolder')
}, },
color: {
icon: 'la:fill',
iconColor: 'orange',
label: t('common.actions.setColor') + '...'
},
duplicate: { duplicate: {
icon: 'la:copy', icon: 'la:copy',
iconColor: 'teal', iconColor: 'teal',

@ -1,9 +1,11 @@
<template> <template>
<li class="treeview-node"> <li class="treeview-node">
<!-- NODE --> <!-- NODE -->
<div class="treeview-label" @click="openNode" :class='{ "active": isActive }'> <div class="treeview-label" @click="openNode" :class="{ active: isActive }">
<w-icon :name="icon" size="sm" @click.stop="toggleNode()" /> <w-icon :name="icon" size="sm" :style="hueFilter" @click.stop="toggleNode()" />
<div class="treeview-label-text">{{displayMode === 'path' ? node.fileName : node.title}}</div> <div class="treeview-label-text">
{{ displayMode === 'path' ? node.fileName : node.title }}
</div>
<w-spinner class="mr-1" color="primary" v-if="state.isLoading" /> <w-spinner class="mr-1" color="primary" v-if="state.isLoading" />
<w-icon <w-icon
v-if="isActive" v-if="isActive"
@ -20,7 +22,7 @@
@before-show="state.isContextMenuShown = true" @before-show="state.isContextMenuShown = true"
@before-hide="state.isContextMenuShown = false"> @before-hide="state.isContextMenuShown = false">
<w-card class="p-2"> <w-card class="p-2">
<w-list dense style="min-width: 150px;"> <w-list dense style="min-width: 150px">
<w-item <w-item
v-for="action of contextActionList" v-for="action of contextActionList"
:key="action.key" :key="action.key"
@ -29,7 +31,9 @@
<w-item-section side> <w-item-section side>
<w-icon :name="action.icon" :color="action.iconColor" /> <w-icon :name="action.icon" :color="action.iconColor" />
</w-item-section> </w-item-section>
<w-item-section :class="action.labelColor && (`text-` + action.labelColor)">{{action.label}}</w-item-section> <w-item-section :class="action.labelColor && `text-` + action.labelColor">{{
action.label
}}</w-item-section>
</w-item> </w-item>
</w-list> </w-list>
</w-card> </w-card>
@ -49,6 +53,7 @@
import { computed, inject, reactive } from 'vue' import { computed, inject, reactive } from 'vue'
import { useDark } from '@/composables/dark' import { useDark } from '@/composables/dark'
import { folderIconStyle } from '@/helpers/folderColors'
import TreeLevel from './TreeLevel.vue' import TreeLevel from './TreeLevel.vue'
@ -69,7 +74,6 @@ const props = defineProps({
} }
}) })
// INJECT // INJECT
const loaded = inject('loaded') const loaded = inject('loaded')
@ -101,6 +105,12 @@ const icon = computed(() => {
: 'img:/_assets/icons/fluent-folder.svg' : 'img:/_assets/icons/fluent-folder.svg'
}) })
/**
* The colour the folder was given, as a filter over the one folder icon every node draws. Nothing at
* all for a folder left the colour it starts out, and for a node drawing an icon of its own.
*/
const hueFilter = computed(() => (props.node.icon ? undefined : folderIconStyle(props.node.hue)))
const hasChildren = computed(() => { const hasChildren = computed(() => {
return props.node.children?.length > 0 return props.node.children?.length > 0
}) })

@ -0,0 +1,46 @@
import { nextTick } from 'vue'
/**
* Apply a change to the app as a single cross-fade, through the View Transition API.
*
* A page swap is not one paint. The title, the description, the breadcrumbs and the article are
* separate pieces of the same screen, and each of them lands in whichever frame it is ready in -- so
* for an instant the reader is looking at half of the page they left and half of the one arriving.
* That is what this is for: the browser holds the old screen still, the callback makes the change,
* and the two states are cross-faded rather than cut between.
*
* The update is awaited before the animation begins, so `update` may be async -- but keep it SHORT.
* Rendering is suppressed for as long as it runs, which is exactly the point (nothing may repaint
* half-swapped) and exactly the danger: a fetch in here freezes the interface for the length of the
* round trip. Fetch first, then call this with the part that touches the DOM.
*
* Resolves once the DOM has been updated, NOT once the animation has finished -- what comes after a
* page swap is more work on the content that just landed (loading its blocks, scrolling to the
* heading in the URL), and none of that should wait on a fade.
*
* @param {() => void|Promise<void>} update Makes the change. Called exactly once, with or without a
* transition -- so this is safe to use as the only path.
*/
export async function withViewTransition(update) {
// -> Vue renders on its own schedule, so the change is not in the DOM until the queue has flushed;
// without this the browser captures the new state before it exists and there is nothing to fade
const applyAndRender = async () => {
await update()
await nextTick()
}
// -> Not in every browser yet (Safari and Firefox trail Chromium here), and nothing about this is
// load-bearing: without the API the change simply happens the way it did before
if (!document.startViewTransition) {
return applyAndRender()
}
const transition = document.startViewTransition(applyAndRender)
/*
`updateCallbackDone` rather than `finished`: it rejects with whatever `update` threw, which is
what the caller wants to see, while `finished` swallows it and resolves anyway. `ready` is the
third one and is deliberately untouched -- it REJECTS when a transition is skipped (a second
navigation before this one settled), which is an ordinary outcome here rather than an error.
*/
await transition.updateCallbackDone
}

@ -839,3 +839,40 @@
--breakpoint-lg: 1440px; --breakpoint-lg: 1440px;
--breakpoint-xl: 1920px; --breakpoint-xl: 1920px;
} }
/*
Page transitions.
The cross-fade one page makes into the next. `composables/viewTransition.js` is the other half: it
hands the whole swap to the View Transition API in one go, and this is what the browser does with
the two snapshots it took either side of it.
Only the duration is set. The animation itself is the browser's own cross-fade, which blends the
outgoing and incoming snapshots with `plus-lighter` inside an isolated pair -- so their opacities
sum to 1 throughout and everything that did NOT change between the two (the header, the sidebar,
the shell around the article) holds perfectly steady rather than dipping through the middle of it.
That property is what makes a whole-document transition usable here instead of naming pieces of the
page individually, and it is why the two halves have to keep the SAME duration and easing: give the
incoming half a curve of its own and the parts that are identical start to flicker.
180ms rather than the browser's 250: this fires on every link a reader follows, and a fade long
enough to notice on the first page is a fade in the way by the fifth.
*/
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 180ms;
}
/*
Nothing moves for a reader who has asked for that. The swap is still a single change -- that part is
the point of it and is not an animation -- it simply arrives as a cut. `animation: none` leaves the
transition with nothing to wait on, so it ends on the next frame; the group is in the list because a
live one there would hold the finished snapshot on screen after the two halves had stopped.
*/
@media (prefers-reduced-motion: reduce) {
::view-transition-group(root),
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}

@ -70,3 +70,19 @@ export const FILES_PREFIX = '/_files/'
export function assetUrl(folderPath, fileName) { export function assetUrl(folderPath, fileName) {
return `${FILES_PREFIX}${folderPath ? `${folderPath}/${fileName}` : fileName}` return `${FILES_PREFIX}${folderPath ? `${folderPath}/${fileName}` : fileName}`
} }
/**
* Where an uploaded file's bytes come from when it is addressed by ID rather than by path.
*
* The API's own download route, which `/_api` fronts and the session cookie authenticates -- so it
* can be handed straight to an `<img>`. Preferred over `assetUrl` wherever the ID is in hand: a path
* exists once per locale and carries none, so `/_files/` answers with the primary locale's file of
* that name, which is a different file whenever another locale is being browsed.
*
* @param {string} siteId UUID of the site the asset belongs to.
* @param {string} assetId UUID of the asset.
* @returns {string} A root-relative URL.
*/
export function assetContentUrl(siteId, assetId) {
return `/_api/sites/${siteId}/assets/${assetId}/content`
}

@ -0,0 +1,50 @@
/**
* The colours a folder can be given in the file manager.
*
* Stored and applied as a HUE ROTATION rather than as a colour: the folder icon is a single yellow
* image that every tree and listing draws, and turning it around the colour wheel is what recolours
* it so the set below is a set of angles, and zero is the icon left alone. That also means a wiki
* that swaps the icon for one of its own keeps every folder's choice meaningful, where a stored
* `#e6a817` would not.
*
* Ten of them, one every 36 degrees: an even sweep of the wheel that is also two full rows of five.
*
* The names are what each angle actually renders as, which is not quite what the arithmetic suggests:
* the CSS filter is a matrix approximation over RGB rather than a rotation in HSL.
*/
export const FOLDER_COLORS = [
{ hue: 0, name: 'Yellow' },
{ hue: 36, name: 'Lime' },
{ hue: 72, name: 'Green' },
{ hue: 108, name: 'Turquoise' },
{ hue: 144, name: 'Cyan' },
{ hue: 180, name: 'Blue' },
{ hue: 216, name: 'Violet' },
{ hue: 252, name: 'Magenta' },
{ hue: 288, name: 'Pink' },
{ hue: 324, name: 'Orange' }
]
/**
* The CSS filter that paints a folder icon its colour.
*
* @param {number} [hue] Degrees around the colour wheel. Absent or zero for the colour every folder
* starts out, which is no filter at all rather than a rotation of nothing
* `filter` creates a containing block and a compositing layer, and a listing
* of a hundred untouched folders should pay for neither.
* @returns {string|undefined} A `filter` value, or undefined to leave the icon alone.
*/
export function folderHueFilter(hue) {
return hue ? `hue-rotate(${hue}deg)` : undefined
}
/**
* The same thing as a style binding, which is how every tree and listing applies it.
*
* @param {number} [hue] Degrees around the colour wheel.
* @returns {object|undefined} A style object, or undefined to bind nothing at all.
*/
export function folderIconStyle(hue) {
const filter = folderHueFilter(hue)
return filter ? { filter } : undefined
}

@ -347,6 +347,7 @@ import { dialog } from '@/composables/dialog'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
import { useMinWidth } from '@/composables/screen' import { useMinWidth } from '@/composables/screen'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { withViewTransition } from '@/composables/viewTransition'
import { loading } from '@/composables/loading' import { loading } from '@/composables/loading'
import { scrollToAnchor, scrollToAnchorWhenReady } from '@/helpers/anchors' import { scrollToAnchor, scrollToAnchorWhenReady } from '@/helpers/anchors'
import { splitLocalePath } from '@/helpers/pagePaths' import { splitLocalePath } from '@/helpers/pagePaths'
@ -594,8 +595,8 @@ watch(
page view (a search, the profile, the admin area), and the store it reads is global: an immediate run page view (a search, the profile, the admin area), and the store it reads is global: an immediate run
fires against whatever page was on screen BEFORE that detour, so leaving a locked page for the search fires against whatever page was on screen BEFORE that detour, so leaving a locked page for the search
screen and coming back to an unprotected one prompted for the earlier page's password. Every real screen and coming back to an unprotected one prompted for the earlier page's password. Every real
case still fires here, because `pageLoad` clears the flag as it starts and the reply sets it again -- case still fires here: the flag is set from the reply, alongside the id of the page it came with, so
so a locked page always arrives as a change, mount or no mount. walking from one protected page to another moves this source from one id to the other.
*/ */
watch( watch(
() => (pageStore.isLocked ? pageStore.id : null), () => (pageStore.isLocked ? pageStore.id : null),
@ -625,7 +626,7 @@ function onHashChange() {
watch( watch(
() => route.path, () => route.path,
async (newValue) => { async (newValue, oldValue) => {
// -> Ignore route change (e.g. from page create route fix) // -> Ignore route change (e.g. from page create route fix)
if (editorStore.ignoreRouteChange) { if (editorStore.ignoreRouteChange) {
editorStore.$patch({ ignoreRouteChange: false }) editorStore.$patch({ ignoreRouteChange: false })
@ -683,11 +684,35 @@ watch(
const pagePath = localePath?.path ?? newValue const pagePath = localePath?.path ?? newValue
const pageLocale = localePath?.locale const pageLocale = localePath?.locale
/*
The moment one page becomes the next, as a single change.
Everything the reader can see is in here -- the scroll position, the title, the description, the
breadcrumbs, the article -- because they used to arrive separately: the header would change over
to the new page while the old article was still under it, and for a frame the screen was half of
each. `withViewTransition` holds the screen still for the length of this function and cross-fades
between what it looked like before and after.
It is also why `scrollPageToTop` is in here rather than ahead of the request, where it used to
be: on the far side of the fetch the jump is not visible at all, the outgoing page being a
snapshot by then, so it no longer has to happen on the page being left.
Not on the immediate run, which is this component mounting rather than the reader going
anywhere: there is no page being left, and the screen behind this one is still the boot splash.
*/
const isFirstRun = oldValue === undefined
const swapTo = (apply) => {
const update = () => {
scrollPageToTop()
apply()
}
return isFirstRun ? update() : withViewTransition(update)
}
// -> Load Page. The contents panel belongs to the page being left, so it goes with it // -> Load Page. The contents panel belongs to the page being left, so it goes with it
state.tocPanelOpen = false state.tocPanelOpen = false
scrollPageToTop()
try { try {
await pageStore.pageLoad({ path: pagePath, locale: pageLocale }) await pageStore.pageLoad({ path: pagePath, locale: pageLocale, applyWith: swapTo })
if (editorStore.isActive) { if (editorStore.isActive) {
/* /*
Walking away from the editor closes it, and `mode` describes the editor that was open so Walking away from the editor closes it, and `mode` describes the editor that was open so
@ -737,7 +762,7 @@ watch(
} else { } else {
// -> Not a notification over the page the reader came from: that page is still on screen // -> Not a notification over the page the reader came from: that page is still on screen
// behind it, at a URL that is not its own. The view draws the missing page instead. // behind it, at a URL that is not its own. The view draws the missing page instead.
pageStore.pageNotFound({ path: pagePath, locale: pageLocale }) await swapTo(() => pageStore.pageNotFound({ path: pagePath, locale: pageLocale }))
/* /*
The one place the page permissions have to be asked for on their own: everywhere else they The one place the page permissions have to be asked for on their own: everywhere else they
arrive with the page, and here there is no page to carry them while the screen about to arrive with the page, and here there is no page to carry them while the screen about to

@ -142,21 +142,19 @@ export const usePageStore = defineStore('page', {
actions: { actions: {
/** /**
* PAGE - LOAD * PAGE - LOAD
*
* Nothing in this store moves until the reply is in hand: the page being read stays on screen,
* whole, for as long as the request takes, and then the next one replaces it in a single change.
* That is what `applyWith` is for -- it wraps the moment everything changes, so the page view can
* hand that moment to the View Transition API and have the two cross-faded. Left at its default
* the change simply happens, which is what a same-page refresh wants.
*
* @param {(apply: () => void) => void|Promise<void>} [applyWith] Runs the given function, which
* is what puts the loaded page into this store. Called only on success, exactly once.
*/ */
async pageLoad({ path, id, locale, withContent = false }) { async pageLoad({ path, id, locale, withContent = false, applyWith = (apply) => apply() }) {
const editorStore = useEditorStore() const editorStore = useEditorStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
/*
The lock, and the absence of a page, belong to the page being loaded rather than to the one
before it.
Everything else in this store stays put until the reply arrives, deliberately -- blanking it
would flash an empty page on every navigation. These two cannot be treated that way: they are
read as "the page on screen is protected" and "there is no page on screen", and left standing
they make the NEXT page look protected, or missing, for as long as the request takes.
*/
this.isLocked = false
this.notFound = false
try { try {
const pageData = await API_CLIENT.get( const pageData = await API_CLIENT.get(
`sites/${siteStore.id}/pages/${id ?? fastHash(normalizePath(path))}`, `sites/${siteStore.id}/pages/${id ?? fastHash(normalizePath(path))}`,
@ -172,25 +170,34 @@ export const usePageStore = defineStore('page', {
throw new Error('ERR_PAGE_NOT_FOUND') throw new Error('ERR_PAGE_NOT_FOUND')
} }
// Update page store // Update page store
this.$patch({ await applyWith(() => {
...pageData, this.$patch({
// -> The field is present exactly when the source came with the page, which is what makes ...pageData,
// the copy in this store safe to save; a view-mode load leaves the previous one in place // -> The field is present exactly when the source came with the page, which is what makes
contentLoaded: Object.hasOwn(pageData, 'content'), // the copy in this store safe to save; a view-mode load leaves the previous one in place
relations: pageData.relations.map((r) => contentLoaded: Object.hasOwn(pageData, 'content'),
pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target']) relations: pageData.relations.map((r) =>
), pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])
localeRelations: (pageData.localeRelations ?? []).map((r) => ),
pick(r, ['locale', 'path', 'title']) localeRelations: (pageData.localeRelations ?? []).map((r) =>
), pick(r, ['locale', 'path', 'title'])
tocDepth: pick(pageData.tocDepth, ['min', 'max']) ),
}) tocDepth: pick(pageData.tocDepth, ['min', 'max']),
this.applyViewerState(pageData.viewer) /*
// Update editor state timestamps The absence of a page is not something the reply carries -- it is what the page view
const curDate = Temporal.Now.instant() says when there was no reply at all -- so arriving at one clears it here. `isLocked`
editorStore.$patch({ needs no such help: every page answers with it, so `...pageData` above has already
lastChangeTimestamp: curDate, settled whether THIS page is protected.
lastSaveTimestamp: curDate */
notFound: false
})
this.applyViewerState(pageData.viewer)
// Update editor state timestamps
const curDate = Temporal.Now.instant()
editorStore.$patch({
lastChangeTimestamp: curDate,
lastSaveTimestamp: curDate
})
}) })
} catch (err) { } catch (err) {
// -> A missing page is an ordinary outcome, not a failure: it is what puts a new instance in // -> A missing page is an ordinary outcome, not a failure: it is what puts a new instance in

Loading…
Cancel
Save