mirror of
https://github.com/requarks/wiki.git
synced 2026-08-26 05:07:30 -05:00
feat: handle pasted uploads + option to specify destination + various fixes
This commit is contained in:
+20
-5
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify'
|
||||
|
||||
import { decodeTreePath } from '../helpers/common.ts'
|
||||
import { decodeTreePath, normalizeFolderPath } from '../helpers/common.ts'
|
||||
import { INLINE_EXTS } from '../models/assets.ts'
|
||||
|
||||
const assetIdParam = {
|
||||
@@ -60,7 +60,7 @@ async function routes(app: FastifyInstance) {
|
||||
*/
|
||||
app.post<{
|
||||
Params: { siteId: string }
|
||||
Querystring: { fileName: string; folderId?: string; locale?: string }
|
||||
Querystring: { fileName: string; folderId?: string; folderPath?: string; locale?: string }
|
||||
}>(
|
||||
'/sites/:siteId/assets',
|
||||
{
|
||||
@@ -94,7 +94,13 @@ async function routes(app: FastifyInstance) {
|
||||
folderId: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'The folder to upload into. The site root when absent.'
|
||||
description: 'The folder to upload into. Wins over `folderPath`.'
|
||||
},
|
||||
folderPath: {
|
||||
type: 'string',
|
||||
maxLength: 2048,
|
||||
description:
|
||||
'Slash-separated path of the folder to upload into, created if it does not exist. The site root when both this and `folderId` are absent.'
|
||||
},
|
||||
locale: {
|
||||
type: 'string',
|
||||
@@ -132,11 +138,19 @@ async function routes(app: FastifyInstance) {
|
||||
return reply.badRequest('No file was sent.')
|
||||
}
|
||||
|
||||
/*
|
||||
Where this is going, as a path, which is what a rule addresses. An ID has to be looked up to
|
||||
get one; a path is already one, and is normalized here rather than trusted -- the model would
|
||||
happily create a folder called `..`.
|
||||
*/
|
||||
const folder = req.query.folderId
|
||||
? await WIKI.models.tree.getFolderById(req.query.folderId)
|
||||
: null
|
||||
const folderPath = folder ? (decodeTreePath(folder.folderPath ?? '') ?? '') : ''
|
||||
const destination = folder ? [folderPath, folder.fileName].filter(Boolean).join('/') : ''
|
||||
const folderPath = req.query.folderId ? null : normalizeFolderPath(req.query.folderPath)
|
||||
const parentPath = folder ? (decodeTreePath(folder.folderPath ?? '') ?? '') : ''
|
||||
const destination = folder
|
||||
? [parentPath, folder.fileName].filter(Boolean).join('/')
|
||||
: (folderPath ?? '')
|
||||
if (
|
||||
!mayOnAsset(req, 'write:assets', { folderPath: destination, fileName: req.query.fileName })
|
||||
) {
|
||||
@@ -146,6 +160,7 @@ async function routes(app: FastifyInstance) {
|
||||
siteId: req.params.siteId,
|
||||
locale: req.query.locale ?? WIKI.sites[req.params.siteId]?.config?.locales?.primary ?? 'en',
|
||||
folderId: req.query.folderId,
|
||||
folderPath,
|
||||
fileName: req.query.fileName,
|
||||
mimeType: req.headers['content-type'],
|
||||
data,
|
||||
|
||||
@@ -116,6 +116,12 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
description:
|
||||
'What an upload does about a file already at the name it wants: replace it in place, refuse the upload, or store the arrival as the next free `name-1.ext`.',
|
||||
enum: ['overwrite', 'reject', 'new']
|
||||
},
|
||||
pastedDestination: {
|
||||
type: 'string',
|
||||
maxLength: 2048,
|
||||
description:
|
||||
"Where a file pasted or dropped into the editor is filed when the page is saved. Empty is the page's own folder. A relative path is a folder under it — `assets` files them in `<page folder>/assets`. A path starting with `/` is from the site root, so every page's pasted files land in the one place. Missing folders are created on the first upload. Normalized on save: doubled slashes and `.`/`..` segments go, and a leading slash is kept because it is what tells the two apart."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -1,5 +1,5 @@
|
||||
import { validate as uuidValidate } from 'uuid'
|
||||
import { CustomError } from '../helpers/common.ts'
|
||||
import { CustomError, normalizePastedDestination } from '../helpers/common.ts'
|
||||
import { detectImageMime, detectSvg, imageMimeTypes, svgMimeType } from '../helpers/images.ts'
|
||||
import { siteAssetKinds } from '../models/sites.ts'
|
||||
import type { SiteAssetKind } from '../models/sites.ts'
|
||||
@@ -476,6 +476,17 @@ async function routes(app: FastifyInstance) {
|
||||
config.features.ratings = config.features.ratingsMode !== 'off'
|
||||
}
|
||||
|
||||
/*
|
||||
The pasted-uploads destination is stored in one form, so that what the admin area reads back is
|
||||
what an upload will do with it -- `assets/`, `./assets` and `assets` are the same folder, and
|
||||
the editor should not have to know that.
|
||||
*/
|
||||
if (config.uploads?.pastedDestination !== undefined) {
|
||||
config.uploads.pastedDestination = normalizePastedDestination(
|
||||
config.uploads.pastedDestination
|
||||
)
|
||||
}
|
||||
|
||||
// -> Update site
|
||||
try {
|
||||
await WIKI.models.sites.updateSite(req.params.siteId, {
|
||||
|
||||
@@ -102,6 +102,45 @@ export function normalizePagePath(input?: string | null): string {
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a folder path to the segments it actually names.
|
||||
*
|
||||
* Wrapping and doubled slashes go, and so do `.` and `..` segments — in a wiki tree those name a
|
||||
* literal folder rather than a relative path, so a caller asking for `../etc` is asking for a folder
|
||||
* called `etc` and never for somewhere outside the site. Which is what makes this safe to hand a path
|
||||
* that came from a request.
|
||||
*
|
||||
* Case is left alone: `encodeTreePath` lowercases on the way into the database, so the lookup does not
|
||||
* care, and the tree is what decides what a new folder ends up called.
|
||||
*/
|
||||
export function normalizeFolderPath(input?: string | null): string {
|
||||
return (input ?? '')
|
||||
.trim()
|
||||
.split('/')
|
||||
.filter((segment) => segment && segment !== '.' && segment !== '..')
|
||||
.join('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce the site's pasted-uploads destination to its stored form.
|
||||
*
|
||||
* `normalizeFolderPath` plus the one thing that setting carries which a plain folder path does not: a
|
||||
* LEADING SLASH, which is what distinguishes a path from the site root from one relative to the page
|
||||
* being edited. So it survives normalization, and `/` on its own stays `/` — the site root, which is a
|
||||
* real answer and a different one from empty (the page's own folder).
|
||||
*/
|
||||
export function normalizePastedDestination(input?: string | null): string {
|
||||
const raw = (input ?? '').trim()
|
||||
if (!raw) {
|
||||
return ''
|
||||
}
|
||||
const normalized = normalizeFolderPath(raw)
|
||||
if (!raw.startsWith('/')) {
|
||||
return normalized
|
||||
}
|
||||
return `/${normalized}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a site's page extension from the end of a URL path.
|
||||
*
|
||||
|
||||
@@ -346,6 +346,9 @@
|
||||
"admin.general.logoUploadSuccess": "Site logo uploaded successfully.",
|
||||
"admin.general.pageExtensions": "Page Extensions",
|
||||
"admin.general.pageExtensionsHint": "A comma-separated list of URL extensions that address a page. For example, adding md redirects /foobar.md to /foobar. These extensions are reserved for pages: a file using one cannot be uploaded as an asset.",
|
||||
"admin.general.pastedDestination": "Pasted Uploads Destination",
|
||||
"admin.general.pastedDestinationHint": "Where files pasted or dropped into the editor are filed when the page is saved, creating folders as needed. Leave empty for the same folder as the page; a relative path (e.g. assets) is a folder under it, and a path starting with / is from the site root.",
|
||||
"admin.general.pastedDestinationPlaceholder": "Same folder as the page",
|
||||
"admin.general.ratingsOff": "Off",
|
||||
"admin.general.ratingsStars": "Stars",
|
||||
"admin.general.ratingsThumbs": "Thumbs",
|
||||
@@ -1825,6 +1828,7 @@
|
||||
"editor.pageRel.title": "Add Page Relation",
|
||||
"editor.pageRel.titleEdit": "Edit Page Relation",
|
||||
"editor.pageScripts.title": "Page Scripts",
|
||||
"editor.pendingAssetsNotInSuggestions": "Images and files cannot be attached to a suggested edit.",
|
||||
"editor.pendingAssetsUploading": "Uploading assets...",
|
||||
"editor.props.alias": "Alias",
|
||||
"editor.props.allowComments": "Allow Comments",
|
||||
|
||||
@@ -286,7 +286,11 @@ class Assets {
|
||||
* `UploadConflictBehavior`. An overwrite returns the existing asset's ID, so a caller that means to
|
||||
* link to what it just uploaded must read the returned name and ID rather than assume its own.
|
||||
*
|
||||
* @param folderId UUID of the folder to upload into. The site root when absent.
|
||||
* @param folderId UUID of the folder to upload into. Takes precedence over `folderPath`.
|
||||
* @param folderPath Slash-separated path of the folder to upload into, created if it does not exist.
|
||||
* The site root when both are absent. A caller that knows a path and not an ID --
|
||||
* the editor uploading what was pasted into a page, which knows the page it is in
|
||||
* -- addresses the folder this way rather than looking it up first.
|
||||
* @param fileName What to call it. Sanitized, so what comes back may differ from what went in.
|
||||
* @param data The file itself.
|
||||
*/
|
||||
@@ -294,6 +298,7 @@ class Assets {
|
||||
siteId,
|
||||
locale,
|
||||
folderId,
|
||||
folderPath,
|
||||
fileName,
|
||||
mimeType,
|
||||
data,
|
||||
@@ -302,6 +307,7 @@ class Assets {
|
||||
siteId: string
|
||||
locale: string
|
||||
folderId?: string | null
|
||||
folderPath?: string | null
|
||||
fileName: string
|
||||
mimeType?: string | null
|
||||
data: Buffer
|
||||
@@ -312,7 +318,14 @@ class Assets {
|
||||
throw new CustomError('assetInvalidFileName', 'This file name cannot be used.')
|
||||
}
|
||||
const fileExt = extensionOf(safeName)
|
||||
await this.guardAgainstPageCollision({ siteId, locale, folderId, fileName: safeName, fileExt })
|
||||
await this.guardAgainstPageCollision({
|
||||
siteId,
|
||||
locale,
|
||||
folderId,
|
||||
folderPath,
|
||||
fileName: safeName,
|
||||
fileExt
|
||||
})
|
||||
// -> The extension decides the type, not the request: the declared one is whatever the client felt
|
||||
// like sending, and this value is what gets served back to a browser later
|
||||
const resolvedMime = mime.getType(safeName) ?? mimeType ?? 'application/octet-stream'
|
||||
@@ -333,6 +346,7 @@ class Assets {
|
||||
siteId,
|
||||
locale,
|
||||
parentId: folderId,
|
||||
parentPath: folderPath,
|
||||
fileName: safeName
|
||||
})
|
||||
if (occupant) {
|
||||
@@ -373,6 +387,7 @@ class Assets {
|
||||
// that was actually free, which is not always the one asked for.
|
||||
const entry = await WIKI.models.tree.addAsset({
|
||||
parentId: folderId,
|
||||
parentPath: folderPath,
|
||||
fileName: safeName,
|
||||
title: safeName,
|
||||
locale,
|
||||
@@ -384,7 +399,9 @@ class Assets {
|
||||
}
|
||||
})
|
||||
const storedName = entry.fileName
|
||||
const folderPath = decodeTreePath(entry.folderPath ?? '') ?? ''
|
||||
// -> Read off the row rather than from the request: the folder may have just been created, and a
|
||||
// name that was taken took the next free one
|
||||
const storedFolderPath = decodeTreePath(entry.folderPath ?? '') ?? ''
|
||||
|
||||
try {
|
||||
// -> The metadata row goes in before the bytes, since the database target writes them into it
|
||||
@@ -405,7 +422,7 @@ class Assets {
|
||||
siteId,
|
||||
actorId: authorId,
|
||||
locale,
|
||||
folderPath,
|
||||
folderPath: storedFolderPath,
|
||||
fileName: storedName,
|
||||
kind,
|
||||
fileSize: data.length
|
||||
@@ -422,7 +439,7 @@ class Assets {
|
||||
WIKI.models.hooks.emit('asset:upload', {
|
||||
id: entry.id,
|
||||
fileName: storedName,
|
||||
folderPath,
|
||||
folderPath: storedFolderPath,
|
||||
siteId,
|
||||
authorId,
|
||||
metadata: { fileSize: data.length, mimeType: resolvedMime, kind }
|
||||
@@ -435,7 +452,7 @@ class Assets {
|
||||
kind,
|
||||
mimeType: resolvedMime,
|
||||
fileSize: data.length,
|
||||
folderPath,
|
||||
folderPath: storedFolderPath,
|
||||
locale,
|
||||
title: entry.title,
|
||||
hasPreview: Boolean(preview),
|
||||
|
||||
@@ -192,7 +192,8 @@ class Sites {
|
||||
}
|
||||
},
|
||||
uploads: {
|
||||
conflictBehavior: 'overwrite'
|
||||
conflictBehavior: 'overwrite',
|
||||
pastedDestination: ''
|
||||
},
|
||||
storage: {
|
||||
largeThreshold: '25MB',
|
||||
@@ -455,7 +456,8 @@ class Sites {
|
||||
contentFont: 'roboto'
|
||||
},
|
||||
uploads: {
|
||||
conflictBehavior: 'overwrite'
|
||||
conflictBehavior: 'overwrite',
|
||||
pastedDestination: ''
|
||||
},
|
||||
storage: {
|
||||
largeThreshold: '25MB',
|
||||
|
||||
@@ -1294,8 +1294,23 @@ function processContent(newContent) {
|
||||
* An image goes in as one, anything else as a link with its file name for text — a dropped PDF is a
|
||||
* link to a PDF, not a broken picture. The name is the image's alt text as well, which is both what the
|
||||
* handler this replaces did and better than nothing for a reader who cannot see it.
|
||||
*
|
||||
* Except while suggesting an edit, where files are refused outright. A pending asset is uploaded when
|
||||
* the page is SAVED, and submitting a suggestion is not a save — nothing would ever send these, so the
|
||||
* markdown would keep a `blob:` URL that dies with the tab. Nor is that only plumbing: somebody
|
||||
* suggesting an edit is by definition somebody without write access to this page, and filing their
|
||||
* files into the wiki beside it is not a decision this flow gets to make. Carrying an attachment on a
|
||||
* suggestion is a feature, and until there is one, the refusal is said out loud — the paste has already
|
||||
* been taken off the browser by the time this runs, so a silent return is a paste that vanished.
|
||||
*/
|
||||
function insertFilesAsAssets(files) {
|
||||
if (editorStore.mode === 'suggest') {
|
||||
notify({
|
||||
type: 'warning',
|
||||
message: t('editor.pendingAssetsNotInSuggestions')
|
||||
})
|
||||
return
|
||||
}
|
||||
const markup = files.map((file) => {
|
||||
const blobUrl = editorStore.addPendingAsset(file)
|
||||
return `${file.type.startsWith('image/') ? '!' : ''}[${file.name}](${blobUrl})`
|
||||
@@ -1359,6 +1374,26 @@ function onEditorDrop(event) {
|
||||
insertFilesAsAssets([...event.dataTransfer.files])
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's model onto the page store: the source a save sends, and the render made from it.
|
||||
*
|
||||
* Debounced because it renders the whole document on every keystroke, and NAMED so that it can also be
|
||||
* flushed — see `reloadEditorContent`, which needs it to have happened before it returns rather than
|
||||
* half a second later.
|
||||
*/
|
||||
const syncContentToStore = debounce(() => {
|
||||
editorStore.$patch({
|
||||
lastChangeTimestamp: Temporal.Now.instant()
|
||||
})
|
||||
pageStore.$patch({
|
||||
content: editor.getValue(),
|
||||
// -> What the author has typed IS the source, whatever the load did or did not deliver; see
|
||||
// the guard in `pageSave`
|
||||
contentLoaded: true
|
||||
})
|
||||
processContent(pageStore.content)
|
||||
}, 500)
|
||||
|
||||
/**
|
||||
* Rewrite text that was already in the editor — the blob URLs of pending assets, once the upload has
|
||||
* given them real paths.
|
||||
@@ -1380,6 +1415,15 @@ function reloadEditorContent({ replacements = [] } = {}) {
|
||||
}
|
||||
if (edits.length > 0) {
|
||||
editor.executeEdits('assets', edits)
|
||||
/*
|
||||
And the store follows the model NOW, rather than when the debounce would have got to it.
|
||||
|
||||
This runs from `UploadPendingAssetsDialog`, immediately before the page is saved. Left to the
|
||||
timer, the sync would land after that save -- so the page would go up with a render still full of
|
||||
`blob:` URLs, and then be marked dirty half a second later by the very edit that fixed it,
|
||||
needing a second save to publish. Flushing here is what makes one save enough.
|
||||
*/
|
||||
syncContentToStore.flush()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1546,29 +1590,29 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
/*
|
||||
Ctrl/Cmd+S, asking for the header's Save button rather than saving anything itself. What that
|
||||
button does is the header's to know -- which of the three it currently is, whether the
|
||||
reason-for-change dialog has to be answered first, and that it is disabled with nothing pending --
|
||||
so the shortcut goes through the event bus instead of reaching for `pageSave` and getting a
|
||||
different save from the one on screen.
|
||||
|
||||
A Monaco action rather than a listener because that is what stops the browser offering to save the
|
||||
page as a file: Monaco takes the keystroke off the event once a keybinding resolves. It has been
|
||||
registered here, doing nothing, for exactly that reason.
|
||||
*/
|
||||
editor.addAction({
|
||||
id: 'save',
|
||||
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
|
||||
label: 'Save',
|
||||
precondition: '',
|
||||
run(ed) {}
|
||||
run(ed) {
|
||||
EVENT_BUS.emit('savePage')
|
||||
}
|
||||
})
|
||||
|
||||
// -> Handle content change
|
||||
editor.onDidChangeModelContent(
|
||||
debounce((ev) => {
|
||||
editorStore.$patch({
|
||||
lastChangeTimestamp: Temporal.Now.instant()
|
||||
})
|
||||
pageStore.$patch({
|
||||
content: editor.getValue(),
|
||||
// -> What the author has typed IS the source, whatever the load did or did not deliver; see
|
||||
// the guard in `pageSave`
|
||||
contentLoaded: true
|
||||
})
|
||||
processContent(pageStore.content)
|
||||
}, 500)
|
||||
)
|
||||
editor.onDidChangeModelContent(syncContentToStore)
|
||||
|
||||
// -> Handle cursor movement
|
||||
editor.onDidChangeCursorPosition(
|
||||
@@ -1728,6 +1772,14 @@ onBeforeUnmount(() => {
|
||||
// -> Before the editor goes: the binding is holding the model, and leaving the room is what takes
|
||||
// this author's avatar out of everyone else's header
|
||||
stopCollabSession()
|
||||
/*
|
||||
Anything pasted but never uploaded goes with the session that held it. This hook is where an
|
||||
editing session ends, whichever way it ended -- discarded, closed, submitted as a suggestion, or
|
||||
walked away from by following a link -- because the editor is mounted exactly while
|
||||
`editorStore.isActive` holds (see `pages/Index.vue`). A save has already emptied this by the time
|
||||
it gets here: the upload runs before the page goes up, not after.
|
||||
*/
|
||||
editorStore.clearPendingAssets()
|
||||
if (editor) {
|
||||
editor.dispose()
|
||||
}
|
||||
|
||||
@@ -328,7 +328,16 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineAsyncComponent, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
reactive,
|
||||
ref,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -429,7 +438,12 @@ const state = reactive({
|
||||
* that watching a page again a minute later rings it again — a class left on plays once and never
|
||||
* plays a second time.
|
||||
*/
|
||||
bellRinging: false
|
||||
bellRinging: false,
|
||||
/**
|
||||
* Whether a save is on the wire. Read only by `savePageFromShortcut`, where the keystroke can repeat
|
||||
* faster than the request comes back; see the note there.
|
||||
*/
|
||||
isSaving: false
|
||||
})
|
||||
|
||||
// REFS
|
||||
@@ -464,8 +478,53 @@ watch(
|
||||
(description) => syncEditable(descriptionEl.value, description)
|
||||
)
|
||||
|
||||
// LIFECYCLE
|
||||
|
||||
/*
|
||||
Ctrl+S in an editor, emitted by whichever editor is open (`EditorMarkdown.vue`). The listener lives
|
||||
here because the buttons do.
|
||||
*/
|
||||
onMounted(() => {
|
||||
EVENT_BUS.on('savePage', savePageFromShortcut)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
EVENT_BUS.off('savePage', savePageFromShortcut)
|
||||
})
|
||||
|
||||
// METHODS
|
||||
|
||||
/**
|
||||
* The keyboard asking for the Save button.
|
||||
*
|
||||
* Which button that is depends on the mode -- a suggestion is submitted, a page that has no row yet is
|
||||
* created through the save-as dialog, everything else is saved in place -- so this dispatches the same
|
||||
* three ways the template does, and stays disabled where they are. A shortcut that saved a page the
|
||||
* button in front of the author could not would be a different action wearing its name.
|
||||
*/
|
||||
function savePageFromShortcut() {
|
||||
if (isSuggesting.value) {
|
||||
if (editorStore.hasPendingChanges) {
|
||||
submitSuggestion()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (editorStore.mode === 'create') {
|
||||
// -> No pending-changes guard, matching its button: a page being created has nothing to compare to
|
||||
createPage()
|
||||
return
|
||||
}
|
||||
/*
|
||||
`state.isSaving` is only ever consulted here. A button cannot be pressed thirty times a second, but
|
||||
Ctrl+S held down auto-repeats, and every repeat that landed before the answer came back would write
|
||||
another version into the page's history. The two branches above need no such guard: both open a
|
||||
dialog, which takes the focus off the editor and ends the repeat.
|
||||
*/
|
||||
if (editorStore.hasPendingChanges && !state.isSaving) {
|
||||
saveChanges(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a value into a contenteditable without disturbing a caret that is already in it.
|
||||
*
|
||||
@@ -612,6 +671,7 @@ async function saveChanges(closeAfter = false) {
|
||||
async function saveChangesCommit(closeAfter = false) {
|
||||
await processPendingAssets()
|
||||
loading.show()
|
||||
state.isSaving = true
|
||||
try {
|
||||
await pageStore.pageSave()
|
||||
notify({
|
||||
@@ -642,6 +702,8 @@ async function saveChangesCommit(closeAfter = false) {
|
||||
message: 'Failed to save page changes.',
|
||||
caption: err.message
|
||||
})
|
||||
} finally {
|
||||
state.isSaving = false
|
||||
}
|
||||
loading.hide()
|
||||
}
|
||||
@@ -685,17 +747,22 @@ async function createPage() {
|
||||
locale: pageStore.locale
|
||||
}
|
||||
}).onOk(async ({ path, title, locale }) => {
|
||||
pageStore.$patch({
|
||||
title,
|
||||
path,
|
||||
// -> The dialog is where the locale is settled for a page that has none yet, so what it
|
||||
// hands back is what the page is written in
|
||||
locale
|
||||
})
|
||||
/*
|
||||
After the patch above, and before the save: pending assets are uploaded into the folder the page
|
||||
is in, and until this dialog answered, the page was not anywhere yet. Uploading first would have
|
||||
filed everything pasted into a new page against wherever the editor was opened from.
|
||||
*/
|
||||
await processPendingAssets()
|
||||
|
||||
loading.show()
|
||||
try {
|
||||
pageStore.$patch({
|
||||
title,
|
||||
path,
|
||||
// -> The dialog is where the locale is settled for a page that has none yet, so what it
|
||||
// hands back is what the page is written in
|
||||
locale
|
||||
})
|
||||
await pageStore.pageSave()
|
||||
notify({
|
||||
type: 'positive',
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useEditorStore } from '@/stores/editor'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
import { usePageStore } from '@/stores/page'
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
import { assetPath } from '@/helpers/assets'
|
||||
import { assetPath, pastedAssetFolder } from '@/helpers/assets'
|
||||
|
||||
// EMITS
|
||||
|
||||
@@ -69,15 +69,30 @@ onMounted(async () => {
|
||||
*/
|
||||
const replacements = []
|
||||
|
||||
// -> Read once, not per file: every file in one save goes to the same place, and the page cannot
|
||||
// move underneath this
|
||||
const folderPath = pastedAssetFolder(siteStore.uploads.pastedDestination, pageStore.folderPath)
|
||||
|
||||
try {
|
||||
for (const item of editorStore.pendingAssets) {
|
||||
state.current++
|
||||
// -> The body is the file itself rather than a multipart form, and the locale is left to the
|
||||
// server, which uses the site's primary one
|
||||
/*
|
||||
The body is the file itself rather than a multipart form.
|
||||
|
||||
Addressed by path because a path is what the editor knows, and any folder in it that does not
|
||||
exist yet is created by the upload. Where that path leads is the site's to say -- see
|
||||
`pastedAssetFolder` -- and it defaults to the page's own folder. The site root is where these
|
||||
used to land regardless, which put every screenshot anybody ever pasted in one flat list beside
|
||||
the site's top-level pages.
|
||||
|
||||
The locale is the page's, not the site's default: a folder belongs to one locale, so a file
|
||||
for the French page has to be filed in the French tree or it is not in the same folder at all.
|
||||
*/
|
||||
const resp = await API_CLIENT.post(`sites/${siteStore.id}/assets`, {
|
||||
searchParams: {
|
||||
fileName: item.fileName
|
||||
// TODO: Upload to page specific folder
|
||||
fileName: item.fileName,
|
||||
locale: pageStore.locale,
|
||||
...(folderPath ? { folderPath } : {})
|
||||
},
|
||||
headers: {
|
||||
'content-type': item.file.type || 'application/octet-stream'
|
||||
|
||||
@@ -21,6 +21,37 @@ export function assetPath(folderPath, fileName) {
|
||||
return folderPath ? `/${folderPath}/${fileName}` : `/${fileName}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Which folder a file pasted or dropped into the editor is filed in.
|
||||
*
|
||||
* The site's `uploads.pastedDestination` decides, and it has three forms — see the setting in the admin
|
||||
* area's General section:
|
||||
*
|
||||
* - **empty**: the page's own folder, so a screenshot sits beside the page that shows it.
|
||||
* - **relative** (`assets`): a folder under the page's own, per page.
|
||||
* - **absolute** (`/media/uploads`): from the site root, so every page's pasted files land together.
|
||||
*
|
||||
* The leading slash is the whole of what separates the last two, which is why the setting keeps one.
|
||||
* `/` alone is therefore the site root, and is a different answer from empty.
|
||||
*
|
||||
* Nothing here checks that the folder exists: the upload creates what it needs. `.` and `..` segments
|
||||
* are dropped rather than followed — in a wiki tree they name a literal folder, never a parent — which
|
||||
* matches what the upload route does with whatever it is sent.
|
||||
*
|
||||
* @param {string} destination The site's `uploads.pastedDestination`.
|
||||
* @param {string} pageFolderPath The folder the page being edited is in, empty at the site root.
|
||||
* @returns {string} A folder path from the site root, empty for the root itself.
|
||||
*/
|
||||
export function pastedAssetFolder(destination, pageFolderPath) {
|
||||
const configured = (destination ?? '').trim()
|
||||
const base = configured.startsWith('/') ? '' : (pageFolderPath ?? '')
|
||||
return [base, configured]
|
||||
.join('/')
|
||||
.split('/')
|
||||
.filter((segment) => segment && segment !== '.' && segment !== '..')
|
||||
.join('/')
|
||||
}
|
||||
|
||||
/** Where uploaded files are served from — `backend/controllers/files.ts`. */
|
||||
export const FILES_PREFIX = '/_files/'
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-web.svg" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 pl-4">
|
||||
<div class="text-h5 admin-page-title animated fadeInLeft">{{ t('admin.general.title') }}</div>
|
||||
<div class="text-h5 admin-page-title animated fadeInLeft">
|
||||
{{ t('admin.general.title') }}
|
||||
</div>
|
||||
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
|
||||
{{ t('admin.general.subtitle') }}
|
||||
</div>
|
||||
@@ -512,6 +514,21 @@
|
||||
:aria-label="t(`admin.general.uploadConflictBehavior`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-item>
|
||||
<blueprint-icon icon="opened-folder" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.general.pastedDestination`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.general.pastedDestinationHint`) }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section>
|
||||
<w-input
|
||||
outlined
|
||||
v-model="state.config.uploads.pastedDestination"
|
||||
dense
|
||||
:placeholder="t(`admin.general.pastedDestinationPlaceholder`)"
|
||||
:aria-label="t(`admin.general.pastedDestination`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-card>
|
||||
<!-- ----------------------- -->
|
||||
<!-- URL Handling -->
|
||||
@@ -766,7 +783,8 @@ async function save() {
|
||||
logoText: state.config.logoText ?? false,
|
||||
sitemap: state.config.sitemap ?? false,
|
||||
uploads: {
|
||||
conflictBehavior: state.config.uploads?.conflictBehavior ?? 'overwrite'
|
||||
conflictBehavior: state.config.uploads?.conflictBehavior ?? 'overwrite',
|
||||
pastedDestination: state.config.uploads?.pastedDestination ?? ''
|
||||
},
|
||||
robots: {
|
||||
index: state.config.robots?.index ?? false,
|
||||
|
||||
@@ -65,6 +65,28 @@ export const useEditorStore = defineStore('editor', {
|
||||
}
|
||||
return blobUrl
|
||||
},
|
||||
/**
|
||||
* Drop every pending asset without uploading any of them.
|
||||
*
|
||||
* What the end of an editing session that did not save means for these. A pending asset becomes a
|
||||
* file only when the page is SAVED, so once the editor is gone nothing is ever going to send them,
|
||||
* and nothing points at them either -- the markdown that did went with the draft that was
|
||||
* discarded.
|
||||
*
|
||||
* Left in place they outlive the editor that made them and are uploaded by the next save instead,
|
||||
* which is very often another page: a file filed into that page's folder that nothing references,
|
||||
* and one nobody chose to upload. `pendingAssets` is not per page, and the rail that lists them is
|
||||
* only drawn while an editor is open, so a leftover is also invisible until it lands.
|
||||
*
|
||||
* Revokes the URLs on the way out, since the browser holds the bytes behind each one until it is
|
||||
* told it can let go.
|
||||
*/
|
||||
clearPendingAssets () {
|
||||
for (const item of this.pendingAssets) {
|
||||
URL.revokeObjectURL(item.blobUrl)
|
||||
}
|
||||
this.pendingAssets = []
|
||||
},
|
||||
async fetchConfigs () {
|
||||
const siteStore = useSiteStore()
|
||||
try {
|
||||
|
||||
@@ -95,6 +95,14 @@ export const useSiteStore = defineStore('site', {
|
||||
reasonForChange: 'required',
|
||||
search: false
|
||||
},
|
||||
/**
|
||||
* What this site does with uploads. Set in the admin area's General section; only the parts the
|
||||
* app itself acts on are carried here, which is where a pasted file goes -- the conflict behavior
|
||||
* is the server's business alone.
|
||||
*/
|
||||
uploads: {
|
||||
pastedDestination: ''
|
||||
},
|
||||
/** How this site handles signing in. Set in the admin area's Login section. */
|
||||
auth: {
|
||||
/**
|
||||
@@ -285,6 +293,10 @@ export const useSiteStore = defineStore('site', {
|
||||
...this.auth,
|
||||
...siteInfo.auth
|
||||
},
|
||||
uploads: {
|
||||
...this.uploads,
|
||||
...siteInfo.uploads
|
||||
},
|
||||
editors: {
|
||||
asciidoc: siteInfo.editors.asciidoc.isActive,
|
||||
markdown: siteInfo.editors.markdown.isActive,
|
||||
|
||||
Reference in New Issue
Block a user