From 7792c4a32c81c89bb34412c04b0c63de2efd1e3e Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 10:03:53 +0200 Subject: [PATCH 1/2] Show a file's history, and read text wherever it lives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine that keeps what a save replaces, and the API that reads a history back, arrived with the batch before this one. Nothing showed them: no mark in a listing, no panel, no way for an administrator to see what histories cost, and no way to read an earlier version at all. This is that half. A file that has earlier versions carries a small mark in the listing, with how many; clicking it opens the panel, which lists what was kept, who saved it and where it came from, and offers reading a version, downloading it, putting it back, naming it, pinning it and deleting it. Putting one back keeps what it replaced, so nothing is lost by going back. The mark is counted once per folder rather than once per row, and a preference turns it off — which takes the query away as well as the icon. An administrator gets the list of every file that has a history, wherever it is, with what each one takes up, and can empty one. Those routes are addressed by the history's own id rather than by a path, because the ones worth finding include files that no longer exist: a history whose file was deleted outside the application has no path left to ask about, and no file to authorise against. They sit behind the administrator check for the same reason. Reading text is now one reader, used by the editor, by a version and by a file still in the trash — the two the batch before this one left out. That reader fixes what the editor used to do with it: - A UTF-16 file was answered "this file appears to be binary and cannot be opened". In UTF-16 every letter of English is accompanied by a zero byte, and a zero byte was exactly the test for binary. That is what `Out-File` wrote by default until PowerShell 6 and what Notepad still offers as "Unicode", so an export or a log from a Windows machine could not be opened. A mark is believed when there is one; otherwise the pairing of zeros decides, which is what tools that write UTF-16 without a mark leave behind. - A save wrote UTF-8 over whatever the file was. That reads perfectly well here and breaks whatever wrote it, so a save now writes back in the encoding the file already had. - The size limit was checked when opening and not when saving: a paste larger than the limit was written, and the file could then never be opened again. It is now checked on the bytes about to be written, which in UTF-16 are twice the characters. - The editor opens two megabytes and saves through a JSON body, whose limit was Express's own default of 100 kB. A file between the two opened and could never be saved, answered "request entity too large" — which names neither limit. The two are one decision now: the body limit is derived from what the editor may open, and a body limit somebody set is a ceiling that is never raised from here, so it is the editor that gives way. The editor's read is also a GET the browser may keep and revalidate, answered 304 while the file is unchanged, so opening the editor from the Markdown preview no longer downloads the same file twice. The identity is taken from the file's metadata, including the inode — a save writes a new file and renames it over the old one, so a save that comes out the same size within one clock tick still differs. An earlier version and a file in the trash open in the editor as text to read: no save, no shortcut that saves, and an editor that does not take typing. Closing goes back where it came from — the folder with the history open again, or the trash, inside the deleted folder the file was read from. Found while wiring this up: `marksForFolder`, `listFilesWithVersions`, `readFileVersions` and `deleteFileVersions` all called store helpers that were never added. Nothing called them, so nothing noticed; all four threw. The queries they need are here, and every one of them now has a test. Left for later, and named here so it is not lost: taking a version out as a new file somewhere, and putting one over another file, both need the destination dialog, which is not here yet; reading a version of an office document needs the office editors to accept one, which comes with the batch that finishes them; and the entry an activity log would want for a purge waits for the log. Gates: lint and formatting on the changed files, `npm run build`, every backend module loads, and the whole backend suite — 1,949 pass, against the two that fail on `main` before any of this. Every claim above has a test that fails when the change behind it is put back; the detection of UTF-16 was mutated twice, once for the mark and once for the pairing, because they are two mechanisms. The screens were checked in the built image: two saves, the mark showing 2 in the listing, the panel opening from it, a version read in the read-only viewer, a version restored from the panel with the history growing to three, a file deleted and read from the trash by double-clicking it, and the administrator's list showing the file, its space and its history. That is also how the last defect here turned up: the dialog that confirms a restore teleports to the body and stacked below the panel's own overlay, so its buttons could not be clicked. No unit test would have seen it. --- backend/src/app.js | 7 +- backend/src/config/index.js | 73 +- backend/src/routes/browse.js | 40 + backend/src/routes/editor.js | 110 ++- backend/src/routes/index.js | 2 + backend/src/routes/trash.js | 16 + backend/src/routes/versions.js | 14 + backend/src/routes/versionsAdmin.js | 80 ++ backend/src/services/settingsService.js | 1 + backend/src/services/textEditorService.js | 331 +++++++++ backend/src/services/trash/index.js | 39 + backend/src/services/trash/operations.js | 33 + backend/src/services/versions/index.js | 14 + backend/src/services/versions/store.js | 164 ++++ backend/src/utils/compressedResponse.js | 208 ++++++ backend/src/utils/textFileResponse.js | 77 ++ backend/tests/routes/text-reading.test.js | 229 ++++++ backend/tests/routes/trash-text.test.js | 159 ++++ backend/tests/routes/version-marks.test.js | 126 ++++ backend/tests/routes/versions-admin.test.js | 190 +++++ frontend/src/api/index.js | 1 + frontend/src/api/trash.api.js | 12 + frontend/src/api/versions.api.js | 109 +++ .../src/components/ExplorerContextMenu.vue | 30 +- frontend/src/components/FileObject.vue | 92 ++- frontend/src/components/InfoPanel.vue | 31 + frontend/src/components/ModalDialog.vue | 12 +- frontend/src/components/VersionsPanel.vue | 700 ++++++++++++++++++ frontend/src/i18n/locales/de.json | 125 +++- frontend/src/i18n/locales/en.json | 125 +++- frontend/src/i18n/locales/es.json | 125 +++- frontend/src/i18n/locales/fr.json | 125 +++- frontend/src/i18n/locales/hi.json | 125 +++- frontend/src/i18n/locales/it.json | 125 +++- frontend/src/i18n/locales/ko.json | 125 +++- frontend/src/i18n/locales/nl.json | 125 +++- frontend/src/i18n/locales/pl.json | 125 +++- frontend/src/i18n/locales/pt-BR.json | 125 +++- frontend/src/i18n/locales/ro.json | 125 +++- frontend/src/i18n/locales/ru.json | 125 +++- frontend/src/i18n/locales/sv.json | 125 +++- frontend/src/i18n/locales/zh-CN.json | 125 +++- frontend/src/i18n/locales/zh-TW.json | 125 +++- frontend/src/layouts/BrowserLayout.vue | 2 + frontend/src/router/index.js | 26 + frontend/src/stores/features.js | 6 + frontend/src/stores/versionsPanel.js | 48 ++ frontend/src/views/EditorView.vue | 155 +++- frontend/src/views/TrashView.vue | 39 +- .../views/settings/SettingsFileVersions.vue | 513 +++++++++++++ .../settings/SettingsUserPreferences.vue | 39 +- frontend/src/views/settings/SettingsView.vue | 7 + 52 files changed, 5445 insertions(+), 165 deletions(-) create mode 100644 backend/src/routes/versionsAdmin.js create mode 100644 backend/src/services/textEditorService.js create mode 100644 backend/src/utils/compressedResponse.js create mode 100644 backend/src/utils/textFileResponse.js create mode 100644 backend/tests/routes/text-reading.test.js create mode 100644 backend/tests/routes/trash-text.test.js create mode 100644 backend/tests/routes/version-marks.test.js create mode 100644 backend/tests/routes/versions-admin.test.js create mode 100644 frontend/src/api/versions.api.js create mode 100644 frontend/src/components/VersionsPanel.vue create mode 100644 frontend/src/stores/versionsPanel.js create mode 100644 frontend/src/views/settings/SettingsFileVersions.vue diff --git a/backend/src/app.js b/backend/src/app.js index f70ecc0b..fbb7eb31 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -22,6 +22,7 @@ const { bootstrap } = require('./utils/bootstrap'); const { configureSession } = require('./middleware/session'); const logger = require('./utils/logger'); const { errorHandler, notFoundHandler } = require('./middleware/errorHandler'); +const { uploads } = require('./config'); /** * Creates and configures the Express application. @@ -49,8 +50,10 @@ const createApp = async (options = {}) => { configureHttpLogging(app); configureCors(app); - app.use(express.json()); - app.use(express.urlencoded({ extended: true })); + // Large enough to carry back whatever the text editor was allowed to open; + // see the reasoning beside the two limits in the configuration. + app.use(express.json({ limit: uploads.maxJsonBodyBytes })); + app.use(express.urlencoded({ extended: true, limit: uploads.maxJsonBodyBytes })); app.use(cookieParser()); app.use(requestContextMiddleware); logger.debug('Mounted cookie parser middleware'); diff --git a/backend/src/config/index.js b/backend/src/config/index.js index 703e6f66..88cb5cb2 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -4,6 +4,8 @@ const env = require('./env'); const constants = require('./constants'); const loggingConfig = require('./logging'); const { parseByteSize } = require('../utils/env'); +// logger reads config/logging, never this file — requiring it here makes no cycle. +const logger = require('../utils/logger'); const parseCommaOrSpaceList = (raw) => { if (!raw) return []; @@ -277,11 +279,75 @@ const searchMaxFileSizeBytes = (() => { })(); // --- Uploads --- +// --- Editor --- +/** + * What the inline text editor opens, and what a JSON request body may weigh. + * + * They are one decision rather than two. The editor sends a file back through a + * JSON body when it saves it, so a body limit under the size the editor opens + * produces a file that opens and cannot be saved — answered with "request + * entity too large", which names neither setting. Express's own default is + * 100 kB, against an editor that opens two megabytes. + * + * Escaping is why the body has to be worth more than the file: in the worst + * case every character of the content is a quote, a backslash or a newline and + * becomes two, and the path travels in the same body. A file whose bytes would + * expand further than that is one the editor refuses to open anyway, as binary. + */ +const JSON_ESCAPE_WORST_CASE = 2; +const JSON_BODY_OVERHEAD_BYTES = 64 * 1024; +const DEFAULT_JSON_BODY_BYTES = 8 * 1024 * 1024; + +const bodyNeededFor = (fileBytes) => fileBytes * JSON_ESCAPE_WORST_CASE + JSON_BODY_OVERHEAD_BYTES; +const fileAllowedBy = (bodyBytes) => + Math.max(0, Math.floor((bodyBytes - JSON_BODY_OVERHEAD_BYTES) / JSON_ESCAPE_WORST_CASE)); + +const { editorMaxFileSizeBytes, maxJsonBodyBytes } = (() => { + const parsedEditor = parseByteSize(env.EDITOR_MAX_FILESIZE); + // Default: 2 MiB if not configured or invalid + const editorAsked = + Number.isFinite(parsedEditor) && parsedEditor > 0 ? parsedEditor : 2 * 1024 * 1024; + + const parsedBody = parseByteSize(env.MAX_JSON_BODY_SIZE); + const bodyWasChosen = Number.isFinite(parsedBody) && parsedBody > 0; + + // A body limit someone set is a ceiling they meant — it is a guard, not a + // detail — so it is never raised from here. The editor is what gives way, and + // it gives way by refusing to open what it could not save back. + if (bodyWasChosen) { + const allowed = fileAllowedBy(parsedBody); + if (editorAsked > allowed) { + logger.warn( + { editorAsked, loweredTo: allowed, maxJsonBodyBytes: parsedBody }, + 'EDITOR_MAX_FILESIZE is larger than MAX_JSON_BODY_SIZE can carry back and has been ' + + 'lowered to match; the editor would otherwise open files it could not save' + ); + } + return { editorMaxFileSizeBytes: Math.min(editorAsked, allowed), maxJsonBodyBytes: parsedBody }; + } + + // Nobody chose the body limit, so the editor's size is the only wish there is + // to honour: the default body limit rises to carry it. + const needed = bodyNeededFor(editorAsked); + if (needed > DEFAULT_JSON_BODY_BYTES) { + logger.info( + { editorMaxFileSizeBytes: editorAsked, maxJsonBodyBytes: needed }, + 'Raised the JSON body limit above its default so the text editor can save what it opens' + ); + } + + return { + editorMaxFileSizeBytes: editorAsked, + maxJsonBodyBytes: Math.max(DEFAULT_JSON_BODY_BYTES, needed), + }; +})(); + // Ceilings for direct (non-chunked) uploads. They exist so a single request // cannot stream until the disk is full; they are generous on purpose, since // large files are a normal use of a file manager. Chunked uploads have their // own storage guard in the TUS service. const uploads = { + maxJsonBodyBytes, maxDirectUploadBytes: (() => { const parsed = parseByteSize(env.MAX_DIRECT_UPLOAD_SIZE); return Number.isFinite(parsed) && parsed > 0 ? parsed : 64 * 1024 * 1024 * 1024; @@ -365,13 +431,6 @@ const collabora = { .filter(Boolean), }; -// --- Editor --- -const editorMaxFileSizeBytes = (() => { - const parsed = parseByteSize(env.EDITOR_MAX_FILESIZE); - // Default: 2 MiB if not configured or invalid - return Number.isFinite(parsed) && parsed > 0 ? parsed : 2 * 1024 * 1024; -})(); - const editor = { extensions: parseExtensionList(env.EDITOR_EXTENSIONS), maxFileSizeBytes: editorMaxFileSizeBytes, diff --git a/backend/src/routes/browse.js b/backend/src/routes/browse.js index 95df16aa..a1895a82 100644 --- a/backend/src/routes/browse.js +++ b/backend/src/routes/browse.js @@ -10,6 +10,42 @@ const { NotFoundError } = require('../errors/AppError'); const router = express.Router(); const { resolvePathWithAccess } = require('../services/accessManager'); const { listDirectoryItems } = require('../services/directoryListingService'); +const versions = require('../services/versions'); +const { rightsFrom: versionRights } = versions; + +/** + * The mark that says a file has earlier versions, for a whole listing. + * + * Counted once for the folder rather than once per row, and only when somebody + * asked to see it — the preference is on by default, and turning it off takes + * the query away as well as the icon, so it costs nothing to somebody who does + * not want it. + * + * The right to see a history is the row's own and not the folder's: a share + * hands out histories only when its owner said so, and that is decided here + * from each child's access rather than from the folder's. + */ +const versionMarks = async (directoryPath, userSettings) => { + if (userSettings?.showVersionMarks === false) return null; + + let marks; + try { + marks = await versions.marksForFolder(directoryPath); + } catch (error) { + // A listing is not worth failing over a count. Nothing is marked, and the + // history is still one right-click away. + logger.warn({ err: error, directoryPath }, 'File versions were not counted for a listing'); + return null; + } + if (!marks || marks.size === 0) return null; + + return ({ name, stats, access }) => { + if (!stats?.isFile()) return null; + const mark = marks.get(name); + if (!mark || !versionRights(access).see) return null; + return { versions: { count: mark.versions, bytes: mark.bytes, newest: mark.newest } }; + }; +}; router.get( '/browse/{*splat}', @@ -49,6 +85,7 @@ router.get( excludeDownloadArtifacts: true, includeHiddenFiles, permissionRules: settings?.access?.rules || [], + itemExtras: await versionMarks(directoryPath, userSettings), }); const response = { @@ -60,6 +97,9 @@ router.get( canDelete: accessInfo.canDelete, canShare: accessInfo.canShare, canDownload: accessInfo.canDownload, + // Whether the files here show their history, which a share hands out + // only when its owner said so. + canSeeVersions: versionRights(accessInfo).see, }, current: { isDirectory: true, diff --git a/backend/src/routes/editor.js b/backend/src/routes/editor.js index 1cbff088..946fe26c 100644 --- a/backend/src/routes/editor.js +++ b/backend/src/routes/editor.js @@ -2,49 +2,29 @@ const express = require('express'); const path = require('path'); const fs = require('fs/promises'); -const config = require('../config'); const { normalizeRelativePath } = require('../utils/pathUtils'); const { ensureDir } = require('../utils/fsUtils'); const { ACTIONS, authorizeAndResolve } = require('../services/authorizationService'); const versions = require('../services/versions/operations'); const asyncHandler = require('../utils/asyncHandler'); +const { sendTextFile } = require('../utils/textFileResponse'); +const { ValidationError, ForbiddenError, NotFoundError } = require('../errors/AppError'); const { - ValidationError, - ForbiddenError, - NotFoundError, - UnsupportedMediaTypeError, -} = require('../errors/AppError'); + readFileEncoding, + encodeText, + MAX_EDITOR_FILE_SIZE, +} = require('../services/textEditorService'); const router = express.Router(); -const MAX_EDITOR_FILE_SIZE = config.editor?.maxFileSizeBytes ?? 1 * 1024 * 1024; -const VIDEO_EXTENSIONS = Array.isArray(config.extensions?.videos) ? config.extensions.videos : []; - -function isProbablyBinaryBuffer(buffer) { - const length = Math.min(buffer.length, 4096); - if (!length) return false; - - let suspicious = 0; - for (let index = 0; index < length; index += 1) { - const byte = buffer[index]; - if (byte === 0) { - return true; - } - if (byte < 7 || (byte > 13 && byte < 32)) { - suspicious += 1; - } - } - - return suspicious / length > 0.3; -} - -async function readTextFileBuffer(req, relative) { +async function resolveReadableFile(req, relative) { if (typeof relative !== 'string' || !relative) { throw new ValidationError('A valid file path is required.'); } const relativePath = normalizeRelativePath(relative); const context = { user: req.user, guestSession: req.guestSession }; + let accessInfo; let resolved; try { @@ -65,51 +45,43 @@ async function readTextFileBuffer(req, relative) { throw new ForbiddenError(accessInfo?.denialReason || 'Access denied.'); } - const { absolutePath } = resolved; - const stats = await fs.stat(absolutePath); - - if (stats.isDirectory()) { - throw new ValidationError('Cannot open a directory in the editor.'); - } - - if (typeof stats.size === 'number' && stats.size > MAX_EDITOR_FILE_SIZE) { - throw new ValidationError('This file is too large to open in the text editor.'); - } - - const ext = path.extname(absolutePath).slice(1).toLowerCase(); - if (VIDEO_EXTENSIONS.includes(ext)) { - throw new UnsupportedMediaTypeError('This file type cannot be opened in the text editor.'); - } + return resolved.absolutePath; +} - const buffer = await fs.readFile(absolutePath); - if (isProbablyBinaryBuffer(buffer)) { - throw new UnsupportedMediaTypeError( - 'This file appears to be binary and cannot be opened in the text editor.' - ); - } +/** + * The editor's read. By GET, which the browser keeps and revalidates, so the + * editor opened from the Markdown preview does not download the file again; by + * POST for the clients written against it, which nothing keeps. + */ +const sendEditorText = async (req, res, relative) => { + const absolutePath = await resolveReadableFile(req, relative); + await sendTextFile(req, res, { absolutePath, render: ({ text }) => ({ content: text }) }); +}; - return { buffer, absolutePath }; -} +router.get( + '/editor', + asyncHandler(async (req, res) => { + await sendEditorText(req, res, req.query?.path); + }) +); router.post( '/editor', asyncHandler(async (req, res) => { const { path: relative = '' } = req.body || {}; - const { buffer } = await readTextFileBuffer(req, relative); - const data = buffer.toString('utf-8'); - res.send({ content: data }); + await sendEditorText(req, res, relative); }) ); router.get( '/raw', asyncHandler(async (req, res) => { - const relative = req.query?.path; - const { buffer } = await readTextFileBuffer(req, relative); - - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.send(buffer.toString('utf-8')); + const absolutePath = await resolveReadableFile(req, req.query?.path); + await sendTextFile(req, res, { + absolutePath, + headers: { 'X-Content-Type-Options': 'nosniff' }, + render: ({ text }) => text, + }); }) ); @@ -161,6 +133,22 @@ router.put( const { absolutePath } = resolved; await ensureDir(path.dirname(absolutePath)); + const existed = await fs + .stat(absolutePath) + .then((stats) => stats.isFile()) + .catch(() => false); + + // Written back in the encoding it already had: a UTF-16 file saved as UTF-8 + // reads perfectly well here and breaks whatever wrote it. + const payload = encodeText(content, existed ? await readFileEncoding(absolutePath) : undefined); + // Refused for the same reason the editor refuses to open it. Without this + // the editor wrote whatever it was given — paste two megabytes into a small + // file, save, and the next attempt to open it answered that the file is too + // large. Measured on the bytes actually written, which is what the size + // limit is about. + if (payload.length > MAX_EDITOR_FILE_SIZE) { + throw new ValidationError('This file is too large to save in the text editor.'); + } // Written beside the file and put in place once whole, with what it // replaces kept as a version: a save used to go straight over the file, so @@ -169,7 +157,7 @@ router.put( // session here to group it with, as there is in the office editors. await versions.saveFile( absolutePath, - (temporaryPath) => fs.writeFile(temporaryPath, content, { encoding: 'utf-8', flag: 'wx' }), + (temporaryPath) => fs.writeFile(temporaryPath, payload, { flag: 'wx' }), { purpose: 'editor', author: versions.authorOf({ user: req.user, guestSession: req.guestSession }), diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index 38680b70..a9d587b6 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -23,6 +23,7 @@ const userVolumesRoutes = require('./userVolumes'); const folderSizeRoutes = require('./folderSize'); const trashRoutes = require('./trash'); const versionsRoutes = require('./versions'); +const versionsAdminRoutes = require('./versionsAdmin'); const { onlyoffice, collabora } = require('../config/index'); const registerRoutes = (app) => { @@ -47,6 +48,7 @@ const registerRoutes = (app) => { app.use('/api', folderSizeRoutes); app.use('/api', trashRoutes); app.use('/api', versionsRoutes); + app.use('/api', versionsAdminRoutes); // User volumes management (admin only, requires USER_VOLUMES feature) app.use('/api', userVolumesRoutes); // Share routes (supports guest sessions) diff --git a/backend/src/routes/trash.js b/backend/src/routes/trash.js index a9ff9f38..d19dab4e 100644 --- a/backend/src/routes/trash.js +++ b/backend/src/routes/trash.js @@ -2,6 +2,7 @@ const express = require('express'); const { sanitizeClientMessage } = require('../middleware/errorHandler'); const asyncHandler = require('../utils/asyncHandler'); +const { sendCompressible } = require('../utils/compressedResponse'); const { startNdjsonStream } = require('../utils/ndjsonStream'); const { ensureAdmin } = require('../middleware/ensureAdmin'); const trash = require('../services/trash'); @@ -48,6 +49,21 @@ router.post( }) ); +/** + * GET /api/trash/items/:id/text?path= - the text of a file in the trash, to read + * before deciding what to do with it: the item itself, or a file inside a + * deleted folder. Read only — there is no route that writes into the trash — + * with the editor's limits on size and binary content, and never cached. + */ +router.get( + '/trash/items/:id/text', + asyncHandler(async (req, res) => { + const text = await trash.readTrashText(req.params.id, req.query.path ?? '', contextOf(req)); + res.set('Cache-Control', 'private, no-store'); + await sendCompressible(req, res, text); + }) +); + /** * Restore into a folder someone chose. Across disks that is a copy, which can * take a while, so it streams its progress the way a transfer does: diff --git a/backend/src/routes/versions.js b/backend/src/routes/versions.js index de590e73..4fb4917e 100644 --- a/backend/src/routes/versions.js +++ b/backend/src/routes/versions.js @@ -3,6 +3,7 @@ const fs = require('fs'); const asyncHandler = require('../utils/asyncHandler'); const logger = require('../utils/logger'); +const { sendCompressible } = require('../utils/compressedResponse'); const { mimeTypes } = require('../config/index'); const versions = require('../services/versions'); const { encodeContentDisposition } = require('./files/utils'); @@ -49,6 +50,19 @@ router.get( }) ); +/** + * The text of a version, for the editor to show read only. Never cached: what a + * version holds does not change, but what this person may read does. + */ +router.get( + '/versions/:id/text', + asyncHandler(async (req, res) => { + const text = await versions.readVersionText(contextOf(req), req.query?.path, req.params.id); + res.set('Cache-Control', 'private, no-store'); + await sendCompressible(req, res, text); + }) +); + router.post( '/versions/:id/restore', asyncHandler(async (req, res) => { diff --git a/backend/src/routes/versionsAdmin.js b/backend/src/routes/versionsAdmin.js new file mode 100644 index 00000000..81605390 --- /dev/null +++ b/backend/src/routes/versionsAdmin.js @@ -0,0 +1,80 @@ +const express = require('express'); + +const asyncHandler = require('../utils/asyncHandler'); +const { ensureAdmin } = require('../middleware/ensureAdmin'); +const versions = require('../services/versions'); + +/** + * Every file that has a history, for an administrator. + * + * Apart from the routes beside it, and deliberately. Those answer about one + * file, named by its path, with that file's own rights — the right shape for + * somebody looking at a document they have open. These answer "where has the + * space gone", which has no one path to ask about: a history whose file was + * deleted outside the application has no file left to authorise against, and + * it is exactly the kind nobody goes looking for. + * + * So a history is named here by its own id, and every route is behind + * `ensureAdmin` — which also refuses an API token, whoever it belongs to. + * + * Under `/versions/admin/` rather than `/versions/files`: `/versions/:id/…` + * already exists, and a first segment that could also be an id is how a route + * ends up meaning two things. + */ +const router = express.Router(); + +/** A page of the files that have versions, narrowed and ordered as asked. */ +router.get( + '/versions/admin/files', + ensureAdmin, + asyncHandler(async (req, res) => { + res.set('Cache-Control', 'private, no-store'); + const { zone, state, q, sort, limit, offset } = req.query || {}; + res.json( + await versions.listFilesWithVersions({ + zoneId: typeof zone === 'string' ? zone : null, + state: typeof state === 'string' ? state : null, + query: typeof q === 'string' ? q : '', + sort: typeof sort === 'string' && sort ? sort : 'bytes', + limit, + offset, + }) + ); + }) +); + +/** One history and its versions, so they can be looked at before being deleted. */ +router.get( + '/versions/admin/files/:id', + ensureAdmin, + asyncHandler(async (req, res) => { + res.set('Cache-Control', 'private, no-store'); + res.json(await versions.readFileVersions(req.params.id)); + }) +); + +/** + * Delete versions of one history: the ones named, or all of them. + * + * A POST with a body rather than a DELETE with a list, as the route beside it + * does, so that deleting forty versions is one request and one answer per + * version — a DELETE per id would report forty times and fail in the middle. + * + * This is the one route here that destroys something, and what it destroys may + * belong to somebody else — so it is the line an activity log would want. There + * is no log yet; when there is one, this is where its entry goes. + */ +router.post( + '/versions/admin/files/:id/delete', + ensureAdmin, + asyncHandler(async (req, res) => { + const outcome = await versions.deleteFileVersions(req.params.id, { + ids: req.body?.ids, + all: req.body?.all === true, + }); + + res.json(outcome); + }) +); + +module.exports = router; diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 05655330..2a9204e6 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -308,6 +308,7 @@ const setUserSetting = async (userId, key, value) => { if ( key === 'showHiddenFiles' || key === 'showThumbnails' || + key === 'showVersionMarks' || key === 'showSidebarFavorites' || key === 'showSidebarShares' || key === 'showSidebarTools' diff --git a/backend/src/services/textEditorService.js b/backend/src/services/textEditorService.js new file mode 100644 index 00000000..d64f20a5 --- /dev/null +++ b/backend/src/services/textEditorService.js @@ -0,0 +1,331 @@ +const crypto = require('crypto'); +const fs = require('fs/promises'); +const path = require('path'); + +const config = require('../config'); +const { ValidationError, UnsupportedMediaTypeError } = require('../errors/AppError'); + +const MAX_EDITOR_FILE_SIZE = config.editor?.maxFileSizeBytes ?? 1 * 1024 * 1024; +const VIDEO_EXTENSIONS = Array.isArray(config.extensions?.videos) ? config.extensions.videos : []; + +/** + * How much of a file is enough to tell text from anything else. + */ +const SAMPLE_BYTES = 4096; + +/** + * How much of a file the pairing test below needs before it is worth believing. + */ +const UTF16_MIN_SAMPLE_BYTES = 16; + +/** + * The byte-order marks that say outright what a file is written in. + * + * UTF-16LE's mark is two bytes, and UTF-32LE's is those same two followed by + * two zeros — so the longer marks are tried first. + */ +const BYTE_ORDER_MARKS = [ + { encoding: 'utf8', bytes: [0xef, 0xbb, 0xbf] }, + { encoding: 'utf16le', bytes: [0xff, 0xfe] }, + { encoding: 'utf16be', bytes: [0xfe, 0xff] }, +]; + +/** + * Whether a run of bytes looks like one half of UTF-16. + * + * In UTF-16 every character in the Latin range is stored as two bytes, one of + * which is zero — the low byte first in little-endian, second in big-endian. + * So a zero byte falling consistently on one side of each pair, and never on + * the other, is the shape of UTF-16 text rather than the shape of a file that + * happens to contain a zero. + */ +const looksLikeUtf16 = (buffer, zeroAtOddIndex) => { + const length = Math.min(buffer.length, SAMPLE_BYTES) & ~1; + // Too short to show a pattern: three bytes of a PNG header would otherwise + // reach the ratio below on their own. + if (length < UTF16_MIN_SAMPLE_BYTES) return false; + + let zerosWhereExpected = 0; + for (let index = 0; index < length; index += 2) { + const [expected, other] = zeroAtOddIndex + ? [buffer[index + 1], buffer[index]] + : [buffer[index], buffer[index + 1]]; + // A zero on the wrong side is not this encoding. + if (other === 0) return false; + // Nor is a pair whose other half is a control byte: that is the shape of a + // file that merely contains zeros, not of text stored two bytes at a time. + if (expected === 0 && (other < 7 || (other > 13 && other < 32))) return false; + if (expected === 0) zerosWhereExpected += 1; + } + + return zerosWhereExpected / (length / 2) > 0.3; +}; + +/** + * What a text file is actually written in. + * + * This exists because of one number: in UTF-16, every ASCII character is + * accompanied by a zero byte, and a zero byte is exactly what "this file is + * binary" looks for. PowerShell's `Out-File` wrote UTF-16LE by default until + * PowerShell 6, and Windows Notepad still offers it as "Unicode", so an export + * or a log from a Windows machine is very often UTF-16 — and was answered with + * "this file appears to be binary and cannot be opened", about a plain text + * file, with no way to tell what was actually meant. + * + * A mark is believed when there is one. Otherwise the pairing above decides, + * because plenty of tools write UTF-16 without one. + * + * @returns {{encoding: 'utf8'|'utf16le'|'utf16be', bom: boolean}} + */ +const detectTextEncoding = (buffer) => { + for (const { encoding, bytes } of BYTE_ORDER_MARKS) { + if (buffer.length >= bytes.length && bytes.every((byte, index) => buffer[index] === byte)) { + return { encoding, bom: true }; + } + } + + if (looksLikeUtf16(buffer, true)) return { encoding: 'utf16le', bom: false }; + if (looksLikeUtf16(buffer, false)) return { encoding: 'utf16be', bom: false }; + + return { encoding: 'utf8', bom: false }; +}; + +/** The characters in a file, whatever it is written in, without its mark. */ +const decodeText = (buffer, { encoding, bom }) => { + if (encoding === 'utf16be') { + // Node decodes little-endian only, so the pairs are swapped first — on a + // copy, because the caller's buffer is not ours to rewrite, and on an even + // number of bytes, because `swap16` throws on anything else and a truncated + // file is not a reason to answer with a stack trace. + const swapped = Buffer.from(buffer.subarray(0, buffer.length & ~1)).swap16(); + return swapped.toString('utf16le').replace(/^\uFEFF/, ''); + } + + const body = bom && encoding === 'utf8' ? buffer.subarray(3) : buffer; + return body.toString(encoding === 'utf16le' ? 'utf16le' : 'utf8').replace(/^\uFEFF/, ''); +}; + +/** + * The bytes to write for text that came out of a file of this encoding. + * + * A file keeps the encoding it had. Saving a UTF-16 log back as UTF-8 would + * halve it and read perfectly well here, and break whatever wrote it — a script + * reading it with a fixed encoding, an import expecting the mark it left. + */ +const encodeText = (text, { encoding = 'utf8', bom = false } = {}) => { + if (encoding === 'utf16le' || encoding === 'utf16be') { + const body = Buffer.from(text, 'utf16le'); + const content = encoding === 'utf16be' ? body.swap16() : body; + return bom + ? Buffer.concat([Buffer.from(encoding === 'utf16be' ? [0xfe, 0xff] : [0xff, 0xfe]), content]) + : content; + } + + const content = Buffer.from(text, 'utf8'); + return bom ? Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), content]) : content; +}; + +/** + * Whether what was read is a file of words at all. + * + * Control characters are the tell either way; what changes with the encoding is + * what a control character is made of. Judging UTF-16 by its bytes is what + * called text binary, so UTF-16 is judged by its characters, after decoding. + */ +function isProbablyBinaryBuffer(buffer) { + const length = Math.min(buffer.length, SAMPLE_BYTES); + if (!length) return false; + + let suspicious = 0; + for (let index = 0; index < length; index += 1) { + const byte = buffer[index]; + if (byte === 0) return true; + if (byte < 7 || (byte > 13 && byte < 32)) suspicious += 1; + } + + return suspicious / length > 0.3; +} + +const isProbablyBinaryText = (text) => { + const length = Math.min(text.length, SAMPLE_BYTES); + if (!length) return false; + + let suspicious = 0; + for (let index = 0; index < length; index += 1) { + const code = text.charCodeAt(index); + if (code === 0) return true; + if (code < 7 || (code > 13 && code < 32)) suspicious += 1; + } + + return suspicious / length > 0.3; +}; + +/** + * What the editor refuses before it has read a byte: a directory, a file past + * the limit, a format the editor is not for. + */ +const refuseUnopenable = (absolutePath, stats) => { + if (stats.isDirectory()) { + throw new ValidationError('Cannot open a directory in the text editor.'); + } + + if (typeof stats.size === 'number' && stats.size > MAX_EDITOR_FILE_SIZE) { + throw new ValidationError('This file is too large to open in the text editor.'); + } + + const ext = path.extname(absolutePath).slice(1).toLowerCase(); + if (VIDEO_EXTENSIONS.includes(ext)) { + throw new UnsupportedMediaTypeError('This file type cannot be opened in the text editor.'); + } +}; + +/** + * And what it refuses once it has seen the start of the file. + * + * The decoded text is passed in when the caller already has it; UTF-16 is + * judged by its characters, so without it the bytes are decoded here. + */ +const refuseBinary = (buffer, detected, decoded = null) => { + const binary = + detected.encoding === 'utf8' + ? isProbablyBinaryBuffer(buffer) + : isProbablyBinaryText(decoded ?? decodeText(buffer, detected)); + + if (binary) { + throw new UnsupportedMediaTypeError( + 'This file appears to be binary and cannot be opened in the text editor.' + ); + } +}; + +/** The first `byteCount` bytes of a file, or as many of them as there are. */ +const readHead = async (absolutePath, byteCount) => { + let handle; + try { + handle = await fs.open(absolutePath, 'r'); + const head = Buffer.alloc(byteCount); + const { bytesRead } = await handle.read(head, 0, byteCount, 0); + return head.subarray(0, bytesRead); + } finally { + await handle?.close(); + } +}; + +async function readTextFile(absolutePath) { + const stats = await fs.stat(absolutePath); + refuseUnopenable(absolutePath, stats); + + const buffer = await fs.readFile(absolutePath); + const detected = detectTextEncoding(buffer); + const text = decodeText(buffer, detected); + refuseBinary(buffer, detected, text); + + return { buffer, stats, text, encoding: detected }; +} + +/** + * How much of a file the judgements above need to reach the verdict the whole + * file would reach. + * + * Twice the sample, because the only one of them that looks at characters + * rather than bytes looks at SAMPLE_BYTES of them, and in UTF-16 a character + * is two bytes. Detection needs no more: a mark is three bytes and the pairing + * test caps itself at SAMPLE_BYTES either way. + */ +const HEAD_BYTES = SAMPLE_BYTES * 2; + +/** + * What a save needs to know about the file it is replacing: that the editor + * would have opened it at all, and what it is written in. + * + * Every refusal `readTextFile` makes, made from the stat and the head of the + * file rather than from the whole of it — which is all any of them ever + * looked at. The save through a share link asked `readTextFile` for the + * encoding alone and so read and decoded up to a megabyte to look at three + * bytes. + * + * @returns {Promise<{stats: import('fs').Stats, encoding: {encoding: string, bom: boolean}}>} + */ +async function readTextFileHead(absolutePath) { + const stats = await fs.stat(absolutePath); + refuseUnopenable(absolutePath, stats); + + const head = await readHead(absolutePath, HEAD_BYTES); + const detected = detectTextEncoding(head); + refuseBinary(head, detected); + + return { stats, encoding: detected }; +} + +/** + * Bumped whenever `readTextFile` would make something different of the same + * bytes: how an encoding is detected, a mark stripped, what counts as binary. + * It is part of the identity below, so a browser holding text decoded under the + * old rules is not told by a 304 that its copy is still right. + */ +const TEXT_READING_VERSION = 1; + +/** + * The identity of the text a file answers with, as a weak ETag, made from the + * file's metadata alone so that an unchanged file can be answered 304 without + * being read. + * + * Taken from a bigint stat — an inode past 2^53 and a time in nanoseconds do + * not survive a double — and made of: + * - the inode: a save writes a new file and renames it over the old one, so a + * save that comes out the same size within one clock tick still differs; + * - the size and the modification time: a write in place; + * - the change time: a write in place that put the modification time back, as + * `cp -p`, `rsync --inplace -t` or an archive extracted over the file do. + * Nothing can put that one back; + * - `describe`, hashed: whatever else the answer carries, so that a change + * there — a share turned read-only — is never hidden behind a 304. + * + * Weak, because the same text goes compressed or not: equivalent answers, not + * the same bytes. + * + * The stat has to be taken before the file is read. Content changing between + * the two then pairs newer text with an older identity, which costs the next + * visit one download; the other order pairs older text with a newer identity, + * and every 304 after it would keep the older text. + * + * @param {import('fs').BigIntStats} stats + * @param {object} [describe] + */ +const textFileEtag = (stats, describe) => { + const parts = [stats.ino, stats.size, stats.mtimeNs, stats.ctimeNs].map((value) => + value.toString(36) + ); + parts.push(`t${TEXT_READING_VERSION}`); + if (describe !== undefined) { + const digest = crypto.createHash('sha256').update(JSON.stringify(describe)).digest('base64url'); + parts.push(digest.slice(0, 16)); + } + return `W/"${parts.join('-')}"`; +}; + +/** + * The encoding a file already on disk is written in, so a save keeps it. + * + * Reads only the head of the file: a mark is the first three bytes, and the + * pairing that betrays a markless UTF-16 shows in the first few hundred. + */ +async function readFileEncoding(absolutePath) { + try { + return detectTextEncoding(await readHead(absolutePath, SAMPLE_BYTES)); + } catch (_) { + // No file yet: a new one is written in the encoding everything else uses. + return { encoding: 'utf8', bom: false }; + } +} + +module.exports = { + readTextFile, + readTextFileHead, + readFileEncoding, + detectTextEncoding, + decodeText, + encodeText, + textFileEtag, + MAX_EDITOR_FILE_SIZE, +}; diff --git a/backend/src/services/trash/index.js b/backend/src/services/trash/index.js index 730a0259..c27fbe6c 100644 --- a/backend/src/services/trash/index.js +++ b/backend/src/services/trash/index.js @@ -25,6 +25,7 @@ const logger = require('../../utils/logger'); const { normalizeRelativePath } = require('../../utils/pathUtils'); const { ACTIONS, authorizeAndResolve, authorizePath } = require('../authorizationService'); const { getDb } = require('../db'); +const { readTextFile } = require('../textEditorService'); const folderSizeManager = require('../folderSizeManager'); const maintenance = require('./maintenance'); const operations = require('./operations'); @@ -430,6 +431,43 @@ const listEntries = async (id, entryPath, context) => { }; }; +/** + * A file in the trash to preview — the item itself, or a file inside a deleted + * folder — for someone who can see the item in their trash. Read only: nothing + * here writes, and nothing outside the item can be reached. + */ +const locateTrashFile = async (id, entryPath, context) => { + const user = requireUser(context); + const normalized = validateEntryPath(entryPath, { allowTop: true }); + const db = await getDb(); + if (!findVisibleFolder(db, id, user)) throw new NotFoundError('This item is not in your trash.'); + + const outcome = await operations.locateFile(id, normalized); + if (outcome.status === 'unavailable') { + throw new ConflictError('The volume this item was deleted from is not available.'); + } + if (outcome.status === 'not-file') throw new ValidationError('This is not a file.'); + if (outcome.status !== 'found') throw new NotFoundError('This file is not in the trash.'); + + return { + absolutePath: outcome.absolutePath, + name: normalized ? path.posix.basename(normalized) : outcome.item.name, + size: outcome.size, + modifiedAt: outcome.modifiedAt, + }; +}; + +/** + * The text of a file in the trash, to read before deciding what to do with it. + * The editor's own limits apply — not too large, not binary — and nothing is + * ever written: this is a look, not an edit. + */ +const readTrashText = async (id, entryPath, context) => { + const file = await locateTrashFile(id, entryPath, context); + const { text } = await readTextFile(file.absolutePath); + return { name: file.name, size: file.size, modifiedAt: file.modifiedAt, content: text }; +}; + /** * Put back entries from inside a deleted folder. Allowed to whoever may restore * the folder itself: an entry goes back inside the folder's own original place, @@ -790,6 +828,7 @@ module.exports = { listItems, restoreItems, listEntries, + readTrashText, restoreEntries, prepareRestoreTo, executeRestoreTo, diff --git a/backend/src/services/trash/operations.js b/backend/src/services/trash/operations.js index fc0b72b8..819a6c12 100644 --- a/backend/src/services/trash/operations.js +++ b/backend/src/services/trash/operations.js @@ -942,6 +942,38 @@ const restoreEntry = async ( } }; +/** + * A file in the trash to read, for a preview: the item itself when it is a + * file, or a file inside a deleted folder. Only a regular file, reached + * through real directories; a symbolic link is never opened. + * + * @returns {Promise<{status: 'found', item: object, absolutePath: string, size: number, + * modifiedAt: string} | {status: 'missing'|'invalid-path'|'not-file'} | + * {status: 'unavailable', reason: string}>} + */ +const locateFile = async (itemId, entryPath = '') => { + const segments = entrySegments(entryPath); + if (!segments) return { status: 'invalid-path' }; + const db = await getDb(); + const item = store.getItem(db, itemId); + if (!item || !['trashed', 'extracting'].includes(item.state)) return { status: 'missing' }; + + const zone = store.getZone(db, item.zoneId); + const inspection = zone ? await zones.inspectZone(zone) : { reason: 'missing' }; + if (!inspection.available) return { status: 'unavailable', reason: inspection.reason }; + + const found = await walkInside(confinedPaths(zone, item.id).payload, segments); + if (!found) return { status: 'missing' }; + if (!found.stats.isFile()) return { status: 'not-file' }; + return { + status: 'found', + item, + absolutePath: found.absolutePath, + size: found.stats.size, + modifiedAt: found.stats.mtime.toISOString(), + }; +}; + /** What an entry inside a deleted folder is — its kind and size — or null when it is not there. */ const describeEntry = async (itemId, entryPath) => { const segments = entrySegments(entryPath); @@ -1325,6 +1357,7 @@ module.exports = { moveToTrash, restoreItem, listEntries, + locateFile, describeEntry, restoreEntry, purgeItem, diff --git a/backend/src/services/versions/index.js b/backend/src/services/versions/index.js index 66250c8b..4bee2b46 100644 --- a/backend/src/services/versions/index.js +++ b/backend/src/services/versions/index.js @@ -30,6 +30,7 @@ const logger = require('../../utils/logger'); const { ensureValidName, normalizeRelativePath } = require('../../utils/pathUtils'); const { ACTIONS, authorizeAndResolve, authorizePath } = require('../authorizationService'); const { getDb } = require('../db'); +const { readTextFile } = require('../textEditorService'); const clock = require('../trash/clock'); const trashStore = require('../trash/store'); const zones = require('../trash/zones'); @@ -272,6 +273,18 @@ const downloadVersion = async (context, relativePath, versionId) => { return { ...located, downloadName: nameForCopy(located.name, located.version) }; }; +/** The text of a version, to read before deciding what to do with it. Nothing is written. */ +const readVersionText = async (context, relativePath, versionId) => { + const located = await locateVersion(context, relativePath, versionId, { download: false }); + const { text } = await readTextFile(located.absolutePath); + return { + name: located.name, + size: located.size, + modifiedAt: located.version.modifiedAt, + content: text, + }; +}; + /** * After a restore, an editor still open on the file holds the content it * replaced. Marking the file says so: that editor's next save is set aside as a @@ -662,6 +675,7 @@ module.exports = { listVersions, locateVersion, downloadVersion, + readVersionText, restoreVersion, copyVersionTo, replaceWithVersion, diff --git a/backend/src/services/versions/store.js b/backend/src/services/versions/store.js index 9722a612..2fa3965a 100644 --- a/backend/src/services/versions/store.js +++ b/backend/src/services/versions/store.js @@ -197,6 +197,165 @@ const setVersionDetails = (db, id, { label, pinned }) => { const deleteVersion = (db, id) => db.prepare('DELETE FROM file_versions WHERE id = ?').run(id).changes; +/** + * Paths, as a LIKE pattern matches them. + * + * A folder called `100%_done` is a wildcard to LIKE, and would have matched + * every sibling. Escaped here rather than at each call site, because there is + * no reading of a query that makes this optional. + */ +const likeLiteral = (value) => String(value).replace(/[\\%_]/g, (character) => `\\${character}`); + +/** + * How many kept versions each file directly inside a folder has. + * + * One query for a whole listing, not one per row: a folder of three hundred + * files costs the same as a folder of three. + * + * The range does the work — `relative_path` is the second column of + * `idx_version_files_path`, and a folder's children all begin with its path + * and a slash. The bound above it is that same path with `0`, the character + * after `/`, so nothing outside the folder is read at all. What the range + * still lets through is the folder's descendants; the `NOT LIKE` drops + * anything with a further slash, which leaves the direct children. + * + * `zoneIds` rather than one zone: a root that has been registered twice over + * the installation's life has two rows, and a file's history may sit under + * either. + */ +const countKeptInFolder = (db, zoneIds, folderPath) => { + const zones = [...new Set((zoneIds || []).filter(Boolean))]; + if (zones.length === 0) return []; + + const prefix = folderPath ? `${folderPath}/` : ''; + const clauses = [ + `vf.zone_id IN (${zones.map(() => '?').join(', ')})`, + "vf.state = 'live'", + "vf.relative_path NOT LIKE ? ESCAPE '\\'", + ]; + const values = [...zones, `${likeLiteral(prefix)}%/%`]; + if (prefix) { + // `dir/` … `dir0`: '/' is 0x2F and '0' is 0x30, so the pair is exactly the + // folder's subtree and nothing adjacent to it. + clauses.push('vf.relative_path >= ?', 'vf.relative_path < ?'); + values.push(prefix, `${folderPath}0`); + } + + return db + .prepare( + `SELECT vf.relative_path AS relativePath, + COUNT(v.id) AS versions, + SUM(v.size_bytes) AS bytes, + MAX(v.modified_at) AS newest + FROM version_files vf + JOIN file_versions v ON v.file_id = vf.id AND v.state = 'kept' + WHERE ${clauses.join(' AND ')} + GROUP BY vf.id` + ) + .all(...values) + .map((row) => ({ + relativePath: row.relativePath, + versions: Number(row.versions) || 0, + bytes: Number(row.bytes) || 0, + newest: row.newest || null, + })); +}; + +/** The states a history can be listed in, and the order they are offered in. */ +const FILE_STATES = ['live', 'trashed', 'orphaned']; + +const ADMIN_SORTS = { + bytes: 'bytes DESC, vf.relative_path ASC', + versions: 'versions DESC, bytes DESC, vf.relative_path ASC', + newest: 'newest DESC, vf.relative_path ASC', + path: 'vf.relative_path ASC, vf.id ASC', +}; + +/** What narrows an administrator's list of histories, said once for both queries. */ +const adminFilter = ({ zoneId = null, state = null, query = '' } = {}) => { + const clauses = []; + const values = []; + if (zoneId) { + clauses.push('vf.zone_id = ?'); + values.push(zoneId); + } + if (state) { + clauses.push('vf.state = ?'); + values.push(state); + } else { + clauses.push(`vf.state IN (${FILE_STATES.map(() => '?').join(', ')})`); + values.push(...FILE_STATES); + } + const wanted = String(query || '').trim(); + if (wanted) { + // `instr` and not LIKE: somebody looking for `report_2026` means that + // underscore, and LIKE would have taken it for any character at all. + clauses.push('instr(lower(vf.relative_path), lower(?)) > 0'); + values.push(wanted); + } + return { where: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '', values }; +}; + +/** + * Every file that has a history, for an administrator. + * + * Grouped in the database rather than counted in the application: the answer + * is one page, and the alternative is reading every version of every file in + * the installation to show twenty-five rows. + */ +const listFilesWithVersions = ( + db, + { zoneId = null, state = null, query = '', sort = 'bytes', limit = 25, offset = 0 } = {} +) => { + const { where, values } = adminFilter({ zoneId, state, query }); + const order = ADMIN_SORTS[sort] || ADMIN_SORTS.bytes; + return db + .prepare( + `SELECT vf.id AS id, vf.zone_id AS zoneId, vf.relative_path AS relativePath, + vf.state AS state, + COUNT(v.id) AS versions, + SUM(v.size_bytes) AS bytes, + MAX(v.modified_at) AS newest + FROM version_files vf + JOIN file_versions v ON v.file_id = vf.id AND v.state = 'kept' + ${where} + GROUP BY vf.id + ORDER BY ${order} + LIMIT ? OFFSET ?` + ) + .all(...values, Math.max(1, limit), Math.max(0, offset)) + .map((row) => ({ + id: row.id, + zoneId: row.zoneId, + relativePath: row.relativePath, + state: row.state, + versions: Number(row.versions) || 0, + bytes: Number(row.bytes) || 0, + newest: row.newest || null, + })); +}; + +/** How many files the same filter matches, and what they hold altogether. */ +const summariseFilesWithVersions = (db, { zoneId = null, state = null, query = '' } = {}) => { + const { where, values } = adminFilter({ zoneId, state, query }); + const row = db + .prepare( + `SELECT COUNT(*) AS files, COALESCE(SUM(bytes), 0) AS bytes, + COALESCE(SUM(versions), 0) AS versions + FROM (SELECT vf.id, SUM(v.size_bytes) AS bytes, COUNT(v.id) AS versions + FROM version_files vf + JOIN file_versions v ON v.file_id = vf.id AND v.state = 'kept' + ${where} + GROUP BY vf.id)` + ) + .get(...values); + return { + files: Number(row?.files) || 0, + bytes: Number(row?.bytes) || 0, + versions: Number(row?.versions) || 0, + }; +}; + module.exports = { mapFile, mapVersion, @@ -215,4 +374,9 @@ module.exports = { setVersionState, setVersionDetails, deleteVersion, + countKeptInFolder, + listFilesWithVersions, + summariseFilesWithVersions, + FILE_STATES, + ADMIN_SORTS, }; diff --git a/backend/src/utils/compressedResponse.js b/backend/src/utils/compressedResponse.js new file mode 100644 index 00000000..f60961ce --- /dev/null +++ b/backend/src/utils/compressedResponse.js @@ -0,0 +1,208 @@ +const zlib = require('zlib'); +const { promisify } = require('util'); + +const logger = require('./logger'); + +const gzip = promisify(zlib.gzip); +const brotliCompress = promisify(zlib.brotliCompress); + +/** + * Sending a whole text file compressed, when the client can take it. + * + * The editor and the Markdown preview receive a file as one JSON document, and + * nothing in the application compressed anything: a 19 MB Markdown file went + * over the wire as 22 MB of JSON, twice when the editor was opened from the + * preview. Text shrinks to a quarter of that. + * + * Deliberately not a global middleware. Most of what this server sends is + * already compressed (images, video, archives, office documents), or streamed + * with a length the client relies on for progress (downloads, ranged media), or + * a stream of progress events that must arrive as they happen (NDJSON) — and a + * middleware buffering or re-encoding those would break them. Only the routes + * that answer with a whole text file in one piece call this. + */ + +/** + * Below this, a body goes as it is. Measured: a 32 KB JSON body gzips in about + * a tenth of a millisecond to 13 KB, so what is saved under the line is a few + * tens of kilobytes — nothing on a local network, and not worth a trip through + * the thread pool for every small file the editor opens. + */ +const COMPRESSION_THRESHOLD_BYTES = 32 * 1024; + +/** + * gzip at level 4. gzip is what a browser asks for over plain http — `br` is + * only advertised over HTTPS — so this is the level a server reached by its + * local address uses, and it decides two things: how long the first transfer + * takes, and whether the browser keeps the answer at all. + * + * Measured asynchronously on 20 MB of JSON, made once of this repository's own + * code and documentation (repeated to reach the size, which gzip's 32 KB window + * cannot see) and once of 5,000 distinct files that never repeat: + * + * repository distinct files + * level 1 5.74 MB 96 ms 4.15 MB 70 ms + * level 3 5.37 MB 123 ms 3.88 MB 88 ms + * level 4 4.98 MB 143 ms 3.58 MB 111 ms + * level 6 4.74 MB 251 ms 3.39 MB 183 ms + * + * On a local network level 1 arrives first, by a few tens of milliseconds. It + * loses on the second count: a browser caps each entry of its cache by the + * bytes it stores, which are the compressed ones. A Chromium measured here kept + * a 5.9 MB answer and not a 6.4 MB one — what a 20 MB Markdown file came to at + * level 1; it came to 5.6 MB at level 4. Past that cap every opening of the file + * is the whole download again, which costs far more than the 40 to 60 ms level + * 4 adds. Level 6 saves little more for nearly twice the time, and each of + * those milliseconds holds one of the four threads file reads share. + */ +const GZIP_LEVEL = 4; + +/** + * Brotli at quality 4: on the same bodies, 4.24 MB in 109 ms and 2.61 MB in + * 77 ms — smaller than gzip at any level, in the time of gzip level 1 to 4. + * Quality 5 took 203 ms for 3.99 MB and 135 ms for 2.41 MB. Offered over HTTPS, + * where the link is more often the slow part. + */ +const BROTLI_QUALITY = 4; + +/** Below any quality a client can write (three decimals), above refusal. */ +const IMPLICIT_QUALITY = 0.0001; + +/** + * The qualities an Accept-Encoding header gives each coding it names. + * + * A malformed quality drops its entry rather than guessing: a coding the client + * did not clearly accept is not one to send it. + */ +const parseAcceptEncoding = (header) => { + const qualities = new Map(); + for (const part of String(header).split(',')) { + const [rawCoding, ...parameters] = part.split(';'); + const coding = rawCoding.trim().toLowerCase(); + if (!coding) continue; + + let quality = 1; + let valid = true; + for (const parameter of parameters) { + const [name, value = ''] = parameter.split('=').map((piece) => piece.trim()); + if (name.toLowerCase() !== 'q') continue; + if (!/^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/.test(value)) { + valid = false; + break; + } + quality = Number(value); + } + // The first mention of a coding is the one that counts. + if (valid && !qualities.has(coding)) qualities.set(coding, quality); + } + return qualities; +}; + +/** + * The content coding to answer with: 'br', 'gzip' or 'identity'. + * + * The client's qualities decide; between equals, the smaller result. Identity + * is acceptable unless the header refuses it (`identity;q=0`, or `*;q=0` without + * naming identity), and when it does refuse everything this server can produce, + * the body still goes uncompressed — the answer HTTP allows rather than a 406 + * nobody would know what to do with. + * + * No header at all is read as "no preference stated", and answered + * uncompressed: an old script or a proxy that sends none may not decode. + */ +const chooseEncoding = (header) => { + if (typeof header !== 'string') return 'identity'; + const qualities = parseAcceptEncoding(header); + const wildcard = qualities.get('*'); + const named = (...codings) => + codings.map((coding) => qualities.get(coding)).find((q) => q !== undefined); + + const candidates = [ + ['br', named('br') ?? wildcard ?? 0], + // x-gzip is the older name, which HTTP asks recipients to treat as gzip. + ['gzip', named('gzip', 'x-gzip') ?? wildcard ?? 0], + ['identity', named('identity') ?? (wildcard === 0 ? 0 : IMPLICIT_QUALITY)], + ]; + + let chosen = 'identity'; + let best = 0; + for (const [coding, quality] of candidates) { + if (quality > best) { + chosen = coding; + best = quality; + } + } + return chosen; +}; + +const compress = (encoding, payload) => + encoding === 'br' + ? brotliCompress(payload, { + params: { + [zlib.constants.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY, + [zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT, + [zlib.constants.BROTLI_PARAM_SIZE_HINT]: payload.length, + }, + }) + : gzip(payload, { level: GZIP_LEVEL }); + +/** + * Send `body` — an object as JSON, a string as text, a Buffer as it is — + * compressed when it is large enough and the client accepts a coding. + * + * Compression runs on the thread pool: a 20 MB body compressed synchronously + * would hold every other request for a tenth of a second or more. + * + * `Vary: Accept-Encoding` is set whatever is chosen, small bodies included, so + * a cache never hands the compressed answer to a client that did not ask for + * it, nor keeps an uncompressed one for everybody. + */ +const sendCompressible = async (req, res, body) => { + let payload; + let type; + if (Buffer.isBuffer(body)) { + payload = body; + type = 'application/octet-stream'; + } else if (typeof body === 'string') { + payload = Buffer.from(body, 'utf8'); + type = 'text/plain; charset=utf-8'; + } else { + payload = Buffer.from(JSON.stringify(body), 'utf8'); + type = 'application/json; charset=utf-8'; + } + + if (!res.getHeader('Content-Type')) res.setHeader('Content-Type', type); + res.vary('Accept-Encoding'); + + const encoding = + payload.length >= COMPRESSION_THRESHOLD_BYTES && !res.getHeader('Content-Encoding') + ? chooseEncoding(req.headers['accept-encoding']) + : 'identity'; + + let sent = payload; + if (encoding !== 'identity') { + try { + const compressed = await compress(encoding, payload); + // Text nearly always shrinks; a body that did not is sent as it was. + if (compressed.length < payload.length) { + sent = compressed; + res.setHeader('Content-Encoding', encoding); + } + } catch (error) { + // The body is still there to send: a failed compression costs bytes, not + // the answer. + logger.warn({ err: error, encoding }, 'A response could not be compressed'); + } + } + + res.setHeader('Content-Length', sent.length); + res.end(sent); +}; + +module.exports = { + sendCompressible, + chooseEncoding, + COMPRESSION_THRESHOLD_BYTES, + GZIP_LEVEL, + BROTLI_QUALITY, +}; diff --git a/backend/src/utils/textFileResponse.js b/backend/src/utils/textFileResponse.js new file mode 100644 index 00000000..30b5e22c --- /dev/null +++ b/backend/src/utils/textFileResponse.js @@ -0,0 +1,77 @@ +const fs = require('fs/promises'); + +const { readTextFile, textFileEtag } = require('../services/textEditorService'); +const { sendCompressible } = require('./compressedResponse'); + +/** + * Kept by the browser and never used without asking: every use is a + * revalidation, answered 304 while the file is unchanged. `private` keeps it + * out of any cache shared between people — what someone may read is decided + * for that person. + */ +const CACHE_CONTROL = 'private, no-cache'; + +/** + * Whether the request's If-None-Match names this identity. + * + * Weak comparison, which is the one If-None-Match uses: `W/"x"` and `"x"` are + * the same tag. A request saying `Cache-Control: no-cache` — a reload — wants + * the file itself and gets it. + */ +const isNotModified = (req, etag) => { + if (req.method !== 'GET' && req.method !== 'HEAD') return false; + const header = req.headers['if-none-match']; + if (!header) return false; + if (/(?:^|,)\s*no-cache\s*(?:,|$)/i.test(req.headers['cache-control'] || '')) return false; + + const opaque = (tag) => tag.replace(/^W\//, ''); + const wanted = opaque(etag); + const tags = header.match(/\*|(?:W\/)?"[^"]*"/g) || []; + return tags.some((tag) => tag === '*' || opaque(tag) === wanted); +}; + +/** + * Answer with the text of a file the caller has already allowed this request + * to read — 304 when the browser holds it unchanged. + * + * Authorization and resolution belong to the caller and come first, always: + * somebody who may not read the file gets the refusal they always got, never a + * 304 telling them their copy is current. + * + * The identity is put on the answer only once there is an answer — a 304, or + * the text read and ready. An error carrying an ETag and `private` may be kept + * by the browser and revalidated like anything else, and a 304 would then keep + * a failure that was only momentary. + * + * @param {object} options + * @param {string} options.absolutePath + * @param {(textFile: object) => object|string} options.render the body, from what readTextFile answers + * @param {object} [options.describe] what else the body carries, made part of its identity + * @param {Record} [options.headers] sent with the text, and with a 304 + * @param {() => Promise} [options.onAnswer] once the answer is decided, before it goes + */ +const sendTextFile = async ( + req, + res, + { absolutePath, render, describe, headers = {}, onAnswer } +) => { + const stats = await fs.stat(absolutePath, { bigint: true }); + const etag = textFileEtag(stats, describe); + + // Only a file was ever given an identity; anything else goes on to the read, + // which says what is wrong with it. + if (stats.isFile() && isNotModified(req, etag)) { + await onAnswer?.(); + res.set({ ...headers, ETag: etag, 'Cache-Control': CACHE_CONTROL }); + res.vary('Accept-Encoding'); + res.status(304).end(); + return; + } + + const body = render(await readTextFile(absolutePath)); + await onAnswer?.(); + res.set({ ...headers, ETag: etag, 'Cache-Control': CACHE_CONTROL }); + await sendCompressible(req, res, body); +}; + +module.exports = { sendTextFile, isNotModified, CACHE_CONTROL }; diff --git a/backend/tests/routes/text-reading.test.js b/backend/tests/routes/text-reading.test.js new file mode 100644 index 00000000..611d0e07 --- /dev/null +++ b/backend/tests/routes/text-reading.test.js @@ -0,0 +1,229 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import express from 'express'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Text a file holds, wherever it is: the file itself, an earlier version of it, + * or a file that is in the trash. + * + * All three answer through one reader, which is the point of it. Before it there + * were three ways to be told a file was unopenable and only one of them was + * true: the editor called a zero byte binary, and in UTF-16 every letter of + * English is accompanied by one — so a log written by PowerShell, or a file + * saved from Notepad as "Unicode", was answered "this file appears to be binary" + * about plain text. A save then wrote UTF-8 over it, which reads perfectly here + * and breaks whatever wrote it. + */ + +const DOCUMENT = 'Notes/journal.md'; + +let env; +let app; +let alice; + +const load = (relative) => require(modulePath(relative)); +const volume = (...segments) => path.join(env.volumeDir, ...segments); + +const write = async (relative, content) => { + await fs.mkdir(path.dirname(volume(relative)), { recursive: true }); + await fs.writeFile(volume(relative), content); +}; + +beforeEach(async () => { + env = await setupTestEnv({ tag: 'text-reading-' }); + + alice = await load('src/services/users').createLocalUser({ + email: 'alice@example.com', + username: 'alice', + displayName: 'Alice', + password: 'secret123', + roles: ['user'], + }); + + app = express(); + // The body limit the application uses: a save has to reach the route before + // the route's own limit can be the one that refuses it. + app.use(express.json({ limit: load('src/config').uploads.maxJsonBodyBytes })); + app.use((req, _res, next) => { + req.user = alice; + next(); + }); + app.use('/api', load('src/routes/editor')); + app.use('/api', load('src/routes/versions')); + app.use(load('src/middleware/errorHandler').errorHandler); +}); + +afterEach(async () => { + load('src/services/trash/maintenance').stop(); + await env.cleanup(); +}); + +const save = (content, file = DOCUMENT) => + request(app).put('/api/editor').send({ path: file, content }); + +const read = (file = DOCUMENT) => request(app).get('/api/editor').query({ path: file }); + +describe('opening a file in the editor', () => { + it('opens a UTF-16 file as the text it is', async () => { + // What `Out-File` wrote by default until PowerShell 6, and what Notepad + // still offers as "Unicode": a mark, then two bytes per character. + await write(DOCUMENT, Buffer.from('quarterly figures', 'utf16le')); + + const response = await read(); + + expect(response.status).toBe(200); + expect(response.body.content).toBe('quarterly figures'); + }); + + it('opens a UTF-16 file that carries no mark', async () => { + await write(DOCUMENT, Buffer.from('the pairing alone has to decide this', 'utf16le')); + + expect((await read()).body.content).toBe('the pairing alone has to decide this'); + }); + + it('still refuses a file that really is binary', async () => { + await write( + DOCUMENT, + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]) + ); + + expect((await read()).status).toBe(415); + }); + + /** + * The editor is opened from the Markdown preview, which has just downloaded + * the same file: the second read is a revalidation, not a download. + */ + it('answers a browser that already holds the file', async () => { + await write(DOCUMENT, 'quarterly figures'); + const first = await read(); + expect(first.headers.etag).toBeTruthy(); + + const again = await read().set('If-None-Match', first.headers.etag); + + expect(again.status).toBe(304); + expect(again.text).toBe(''); + }); + + it('hands the file over again once it has changed', async () => { + await write(DOCUMENT, 'quarterly figures'); + const first = await read(); + await write(DOCUMENT, 'revised figures'); + + const again = await read().set('If-None-Match', first.headers.etag); + + expect(again.status).toBe(200); + expect(again.body.content).toBe('revised figures'); + }); +}); + +describe('saving from the editor', () => { + /** + * The editor opens two megabytes and saves through a JSON body, whose limit + * was Express's own default of 100 kB: a file between the two opened and + * could never be saved, answered "request entity too large" — which names + * neither limit. The body limit is now derived from the editor's. + */ + it('saves a file larger than a default JSON body', async () => { + await write(DOCUMENT, 'small'); + const bigger = 'x'.repeat(200 * 1024); + + expect((await save(bigger)).status).toBe(200); + + expect(await fs.readFile(volume(DOCUMENT), 'utf8')).toBe(bigger); + }); + + it('writes back in the encoding the file already had', async () => { + await write(DOCUMENT, Buffer.from('first', 'utf16le')); + + expect((await save('second')).status).toBe(200); + + const bytes = await fs.readFile(volume(DOCUMENT)); + expect(bytes.subarray(0, 2)).toEqual(Buffer.from([0xff, 0xfe])); + expect(bytes.toString('utf16le').replace('', '')).toBe('second'); + // And it reads back as what was typed, not as two bytes per character. + expect((await read()).body.content).toBe('second'); + }); + + it('writes a new file in UTF-8', async () => { + expect((await save('brand new')).status).toBe(200); + + expect(await fs.readFile(volume(DOCUMENT), 'utf8')).toBe('brand new'); + }); + + /** + * The size limit was checked when opening and not when saving, so a paste + * larger than the limit was written and then could not be opened again. + */ + it('refuses a save larger than the editor can open', async () => { + await write(DOCUMENT, 'small'); + const limit = load('src/services/textEditorService').MAX_EDITOR_FILE_SIZE; + + const refused = await save('x'.repeat(limit + 1)); + + expect(refused.status).toBe(400); + expect(await fs.readFile(volume(DOCUMENT), 'utf8')).toBe('small'); + }); + + /** + * In UTF-16 the same text is twice the bytes, and the bytes are what the + * limit is about: a file just under it in UTF-8 is over it here. + */ + it('counts the bytes it is about to write, not the characters', async () => { + await write(DOCUMENT, Buffer.from('small', 'utf16le')); + const limit = load('src/services/textEditorService').MAX_EDITOR_FILE_SIZE; + + const refused = await save('x'.repeat(limit - 10)); + + expect(refused.status).toBe(400); + }); +}); + +describe('reading a version as text', () => { + const textOf = (id, forPath = DOCUMENT) => + request(app).get(`/api/versions/${id}/text`).query({ path: forPath }); + + const history = () => request(app).get('/api/versions').query({ path: DOCUMENT }); + + it('hands over what a version holds, with its name', async () => { + await save('first'); + await save('second'); + const [version] = (await history()).body.versions; + + const response = await textOf(version.id); + + expect(response.status).toBe(200); + expect(response.body.content).toBe('first'); + expect(response.body.name).toBe('journal.md'); + }); + + it('decodes a version written in UTF-16', async () => { + await write(DOCUMENT, Buffer.from('first', 'utf16le')); + await save('second'); + const [version] = (await history()).body.versions; + + expect((await textOf(version.id)).body.content).toBe('first'); + }); + + it('refuses a version that belongs to another file', async () => { + await save('first'); + await save('second'); + const [version] = (await history()).body.versions; + await save('elsewhere', 'Notes/other.md'); + + expect((await textOf(version.id, 'Notes/other.md')).status).toBe(404); + }); + + /** A version is read, never kept: two people's rights differ on the same bytes. */ + it('never lets a version be cached', async () => { + await save('first'); + await save('second'); + const [version] = (await history()).body.versions; + + expect((await textOf(version.id)).headers['cache-control']).toBe('private, no-store'); + }); +}); diff --git a/backend/tests/routes/trash-text.test.js b/backend/tests/routes/trash-text.test.js new file mode 100644 index 00000000..2ffa12c8 --- /dev/null +++ b/backend/tests/routes/trash-text.test.js @@ -0,0 +1,159 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import express from 'express'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Reading a file that is in the trash. + * + * Deciding whether to restore something or delete it for good means looking at + * it, and until now the trash could only be looked at from the outside: a name, + * a size and a date. The only route that reached into an item listed a deleted + * folder's entries; nothing could open one. + * + * Read only, deliberately: there is no route that writes into the trash, so a + * file goes back to a volume before it can be changed. + */ + +let env; +let app; +let users; + +const load = (relative) => require(modulePath(relative)); +const volume = (...segments) => path.join(env.volumeDir, ...segments); + +const write = async (relative, content) => { + await fs.mkdir(path.dirname(volume(relative)), { recursive: true }); + await fs.writeFile(volume(relative), content); +}; + +const as = (who) => ({ + get: (url) => request(app).get(url).set('x-test-user', who), + del: (url, body) => request(app).delete(url).set('x-test-user', who).send(body), +}); + +const trashAs = (who, parent, name) => + as(who).del('/api/files', { items: [{ path: parent, name }] }); + +beforeEach(async () => { + env = await setupTestEnv({ tag: 'trash-text-', env: { USER_DIR_ENABLED: 'true' } }); + + const usersService = load('src/services/users'); + const make = (name, roles) => + usersService.createLocalUser({ + email: `${name}@example.com`, + username: name, + displayName: name[0].toUpperCase() + name.slice(1), + password: 'secret123', + roles, + }); + users = { alice: await make('alice', ['user']), bob: await make('bob', ['user']) }; + + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + const who = req.get('x-test-user'); + if (who) req.user = users[who]; + next(); + }); + app.use('/api', load('src/routes/files')); + app.use('/api', load('src/routes/trash')); + app.use(load('src/middleware/errorHandler').errorHandler); +}); + +afterEach(async () => { + load('src/services/trash/maintenance').stop(); + await env.cleanup(); +}); + +const textOf = (who, id, entryPath) => + as(who).get( + `/api/trash/items/${id}/text${entryPath ? `?path=${encodeURIComponent(entryPath)}` : ''}` + ); + +describe('a file in the trash', () => { + it('shows its text, with the name it had', async () => { + await write('Projects/report.txt', 'quarterly figures'); + const { body } = await trashAs('alice', 'Projects', 'report.txt'); + const id = body.items[0].trashItemId; + + const response = await textOf('alice', id); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ name: 'report.txt', content: 'quarterly figures' }); + }); + + it('shows a file from inside a deleted folder', async () => { + await write('Projects/notes/minutes.md', 'what was decided'); + const { body } = await trashAs('alice', 'Projects', 'notes'); + const id = body.items[0].trashItemId; + + const response = await textOf('alice', id, 'minutes.md'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ name: 'minutes.md', content: 'what was decided' }); + }); + + it('decodes a file written in UTF-16, as the editor does', async () => { + await write('Projects/export.txt', Buffer.from('written on Windows', 'utf16le')); + const { body } = await trashAs('alice', 'Projects', 'export.txt'); + + const response = await textOf('alice', body.items[0].trashItemId); + + expect(response.body.content).toBe('written on Windows'); + }); + + it('refuses a file that is not text', async () => { + await write( + 'Projects/image.bin', + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 13, 1, 2, 3, 4]) + ); + const { body } = await trashAs('alice', 'Projects', 'image.bin'); + + expect((await textOf('alice', body.items[0].trashItemId)).status).toBe(415); + }); + + it('refuses a folder', async () => { + await write('Projects/notes/minutes.md', 'what was decided'); + const { body } = await trashAs('alice', 'Projects', 'notes'); + + expect((await textOf('alice', body.items[0].trashItemId)).status).toBe(400); + }); + + /** The whole point of the item id: nothing outside the deleted item is reachable. */ + it('refuses a path that climbs out of the item', async () => { + await write('Projects/notes/minutes.md', 'what was decided'); + await write('Projects/secret.txt', 'not deleted'); + const { body } = await trashAs('alice', 'Projects', 'notes'); + + const response = await textOf('alice', body.items[0].trashItemId, '../secret.txt'); + + expect(response.status).toBe(400); + }); + + it('is not readable by somebody it is not in the trash of', async () => { + await write('Projects/report.txt', 'quarterly figures'); + const { body } = await trashAs('alice', 'Projects', 'report.txt'); + + const response = await textOf('bob', body.items[0].trashItemId); + + expect(response.status).toBe(404); + }); + + it('answers nothing for an item that is not there', async () => { + expect((await textOf('alice', 'nosuchitem')).status).toBe(404); + }); + + /** What one person may read is decided for that person, so no cache holds it. */ + it('is never cached', async () => { + await write('Projects/report.txt', 'quarterly figures'); + const { body } = await trashAs('alice', 'Projects', 'report.txt'); + + const response = await textOf('alice', body.items[0].trashItemId); + + expect(response.headers['cache-control']).toBe('private, no-store'); + }); +}); diff --git a/backend/tests/routes/version-marks.test.js b/backend/tests/routes/version-marks.test.js new file mode 100644 index 00000000..360836b8 --- /dev/null +++ b/backend/tests/routes/version-marks.test.js @@ -0,0 +1,126 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import express from 'express'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * The mark in a listing that says a file has earlier versions. + * + * Counted once for the whole folder rather than once per row: a folder of three + * hundred files costs the same query as a folder of three. What it answers is + * what the file browser needs to show a small clock beside a name — which is + * how anybody finds out there is a history to look at. + */ + +let env; +let app; +let alice; + +const load = (relative) => require(modulePath(relative)); +const volume = (...segments) => path.join(env.volumeDir, ...segments); + +const save = (file, content) => request(app).put('/api/editor').send({ path: file, content }); + +const listing = (folder) => request(app).get(`/api/browse/${folder}`); + +const named = (body, name) => body.items.find((item) => item.name === name); + +beforeEach(async () => { + env = await setupTestEnv({ tag: 'version-marks-' }); + + alice = await load('src/services/users').createLocalUser({ + email: 'alice@example.com', + username: 'alice', + displayName: 'Alice', + password: 'secret123', + roles: ['user'], + }); + await fs.mkdir(volume('Notes'), { recursive: true }); + + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = alice; + next(); + }); + app.use('/api', load('src/routes/browse')); + app.use('/api', load('src/routes/editor')); + app.use(load('src/middleware/errorHandler').errorHandler); +}); + +afterEach(async () => { + load('src/services/trash/maintenance').stop(); + await env.cleanup(); +}); + +describe('a folder listing', () => { + it('marks the files that have versions, and how many', async () => { + await save('Notes/journal.md', 'first'); + await save('Notes/journal.md', 'second'); + await save('Notes/journal.md', 'third'); + await save('Notes/once.md', 'only ever saved once'); + + const response = await listing('Notes'); + + expect(response.status).toBe(200); + expect(named(response.body, 'journal.md').versions).toMatchObject({ count: 2 }); + expect(named(response.body, 'journal.md').versions.bytes).toBeGreaterThan(0); + // A file saved once replaced nothing, so it has no history and no mark. + expect(named(response.body, 'once.md').versions).toBeUndefined(); + }); + + it('says whether histories may be seen here at all', async () => { + const response = await listing('Notes'); + + expect(response.body.access.canSeeVersions).toBe(true); + }); + + /** A folder's own files, not its subfolders': the count must not climb. */ + it('counts only the files directly in the folder', async () => { + await fs.mkdir(volume('Notes/deeper'), { recursive: true }); + await save('Notes/deeper/inside.md', 'first'); + await save('Notes/deeper/inside.md', 'second'); + + const response = await listing('Notes'); + + expect(named(response.body, 'deeper').versions).toBeUndefined(); + expect(named(response.body, 'deeper')).toBeTruthy(); + }); + + it('leaves the marks out for somebody who turned them off', async () => { + await save('Notes/journal.md', 'first'); + await save('Notes/journal.md', 'second'); + await load('src/services/settingsService').setUserSetting(alice.id, 'showVersionMarks', false); + + const response = await listing('Notes'); + + expect(named(response.body, 'journal.md').versions).toBeUndefined(); + }); + + /** + * A listing is not worth failing over a count. The history is still one + * right-click away, so a folder whose marks cannot be counted still lists. + */ + it('still lists a folder whose versions cannot be counted', async () => { + await save('Notes/journal.md', 'first'); + await save('Notes/journal.md', 'second'); + const store = load('src/services/versions/store'); + const original = store.countKeptInFolder; + store.countKeptInFolder = () => { + throw new Error('the index is unreadable'); + }; + + try { + const response = await listing('Notes'); + + expect(response.status).toBe(200); + expect(named(response.body, 'journal.md')).toBeTruthy(); + expect(named(response.body, 'journal.md').versions).toBeUndefined(); + } finally { + store.countKeptInFolder = original; + } + }); +}); diff --git a/backend/tests/routes/versions-admin.test.js b/backend/tests/routes/versions-admin.test.js new file mode 100644 index 00000000..c617ee38 --- /dev/null +++ b/backend/tests/routes/versions-admin.test.js @@ -0,0 +1,190 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import express from 'express'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Every file that has a history, for an administrator. + * + * The routes beside these answer about one file, named by its path, with that + * file's own rights — right for somebody looking at a document they have open, + * and no use at all for "where has the space gone". A history whose file was + * deleted outside the application has no file left to authorise against, and it + * is exactly the kind nobody goes looking for. + * + * So a history is named here by its own id, and every route is behind the + * administrator check. + */ + +let env; +let app; +let users; + +const load = (relative) => require(modulePath(relative)); +const volume = (...segments) => path.join(env.volumeDir, ...segments); + +const save = (who, file, content) => + request(app).put('/api/editor').set('x-test-user', who).send({ path: file, content }); + +const as = (who) => ({ + get: (url) => request(app).get(url).set('x-test-user', who), + post: (url, body) => request(app).post(url).set('x-test-user', who).send(body), +}); + +beforeEach(async () => { + env = await setupTestEnv({ tag: 'versions-admin-' }); + + const usersService = load('src/services/users'); + const make = (name, roles) => + usersService.createLocalUser({ + email: `${name}@example.com`, + username: name, + displayName: name[0].toUpperCase() + name.slice(1), + password: 'secret123', + roles, + }); + users = { admin: await make('admin', ['admin']), alice: await make('alice', ['user']) }; + + await fs.mkdir(volume('Notes'), { recursive: true }); + + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + const who = req.get('x-test-user'); + if (who) req.user = users[who]; + next(); + }); + app.use('/api', load('src/routes/editor')); + app.use('/api', load('src/routes/versionsAdmin')); + app.use(load('src/middleware/errorHandler').errorHandler); +}); + +afterEach(async () => { + load('src/services/trash/maintenance').stop(); + await env.cleanup(); +}); + +/** Two saves make one version; that is the whole setup every test here needs. */ +const withHistory = async (file) => { + await save('alice', file, 'first'); + await save('alice', file, 'second'); +}; + +describe('the list of files that have versions', () => { + it('names every file with a history, and what it costs', async () => { + await withHistory('Notes/journal.md'); + await withHistory('Notes/other.md'); + + const response = await as('admin').get('/api/versions/admin/files'); + + expect(response.status).toBe(200); + expect(response.body.total).toBe(2); + expect(response.body.files.map((file) => file.name).sort()).toEqual(['journal.md', 'other.md']); + expect(response.body.totalVersions).toBe(2); + expect(response.body.totalBytes).toBeGreaterThan(0); + }); + + it('narrows to what the search names', async () => { + await withHistory('Notes/journal.md'); + await withHistory('Notes/other.md'); + + const response = await as('admin').get('/api/versions/admin/files?q=journal'); + + expect(response.body.files.map((file) => file.name)).toEqual(['journal.md']); + }); + + it('refuses an order it does not know, rather than picking one', async () => { + const response = await as('admin').get('/api/versions/admin/files?sort=whatever'); + + expect(response.status).toBe(400); + }); + + it('is refused to somebody who is not an administrator', async () => { + await withHistory('Notes/journal.md'); + + expect((await as('alice').get('/api/versions/admin/files')).status).toBe(403); + }); + + it('is refused to nobody at all', async () => { + expect((await request(app).get('/api/versions/admin/files')).status).toBe(403); + }); + + /** An administrator's list of what is on the disks is nobody's cache to keep. */ + it('is never cached', async () => { + const response = await as('admin').get('/api/versions/admin/files'); + + expect(response.headers['cache-control']).toBe('private, no-store'); + }); +}); + +describe('one history, by its id', () => { + it('reads back its versions', async () => { + await withHistory('Notes/journal.md'); + const [file] = (await as('admin').get('/api/versions/admin/files')).body.files; + + const response = await as('admin').get(`/api/versions/admin/files/${file.id}`); + + expect(response.status).toBe(200); + expect(response.body.file.name).toBe('journal.md'); + expect(response.body.versions).toHaveLength(1); + }); + + it('answers nothing for a history that does not exist', async () => { + expect((await as('admin').get('/api/versions/admin/files/nosuch')).status).toBe(404); + }); + + it('is refused to somebody who is not an administrator', async () => { + await withHistory('Notes/journal.md'); + const [file] = (await as('admin').get('/api/versions/admin/files')).body.files; + + expect((await as('alice').get(`/api/versions/admin/files/${file.id}`)).status).toBe(403); + }); +}); + +describe('deleting versions from the administrator side', () => { + it('deletes the ones it is given, and leaves the file alone', async () => { + await withHistory('Notes/journal.md'); + const [file] = (await as('admin').get('/api/versions/admin/files')).body.files; + const { versions } = (await as('admin').get(`/api/versions/admin/files/${file.id}`)).body; + + const response = await as('admin').post(`/api/versions/admin/files/${file.id}/delete`, { + ids: [versions[0].id], + }); + + expect(response.status).toBe(200); + expect(response.body.deleted).toBe(1); + expect(await fs.readFile(volume('Notes/journal.md'), 'utf8')).toBe('second'); + }); + + it('empties a whole history when asked to', async () => { + await save('alice', 'Notes/journal.md', 'first'); + await save('alice', 'Notes/journal.md', 'second'); + await save('alice', 'Notes/journal.md', 'third'); + const [file] = (await as('admin').get('/api/versions/admin/files')).body.files; + + const response = await as('admin').post(`/api/versions/admin/files/${file.id}/delete`, { + all: true, + }); + + expect(response.body.deleted).toBe(2); + expect(response.body.remaining).toBe(0); + expect(await fs.readFile(volume('Notes/journal.md'), 'utf8')).toBe('third'); + }); + + it('is refused to somebody who is not an administrator', async () => { + await withHistory('Notes/journal.md'); + const [file] = (await as('admin').get('/api/versions/admin/files')).body.files; + + const response = await as('alice').post(`/api/versions/admin/files/${file.id}/delete`, { + all: true, + }); + + expect(response.status).toBe(403); + expect( + (await as('admin').get(`/api/versions/admin/files/${file.id}`)).body.versions + ).toHaveLength(1); + }); +}); diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index d3295f11..717dbf74 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -15,3 +15,4 @@ export * from './collabora.api'; export * from './features.api'; export * from './terminal.api'; export * from './trash.api'; +export * from './versions.api'; diff --git a/frontend/src/api/trash.api.js b/frontend/src/api/trash.api.js index 87259ab2..e1eb9718 100644 --- a/frontend/src/api/trash.api.js +++ b/frontend/src/api/trash.api.js @@ -26,6 +26,17 @@ async function getTrashEntries(id, entryPath = '') { }); } +/** + * The text of a file in the trash — the item itself, or a file at `entryPath` + * inside a deleted folder — to read, never to change: `{ name, content, … }`. + */ +async function getTrashFileText(id, entryPath = '') { + const query = entryPath ? `?path=${encodeURIComponent(entryPath)}` : ''; + return requestJson(`/api/trash/items/${encodeURIComponent(id)}/text${query}`, { + method: 'GET', + }); +} + /** Put back entries from inside a deleted folder; the rest of it stays in the trash. */ async function restoreTrashEntries(id, paths, { shares } = {}) { return post( @@ -81,6 +92,7 @@ async function runTrashMaintenance() { export { getTrash, getTrashEntries, + getTrashFileText, restoreTrashItems, restoreTrashEntries, restoreTrashItemsTo, diff --git a/frontend/src/api/versions.api.js b/frontend/src/api/versions.api.js new file mode 100644 index 00000000..6a0b8937 --- /dev/null +++ b/frontend/src/api/versions.api.js @@ -0,0 +1,109 @@ +import { buildUrl, normalizePath, requestJson } from './http'; + +/** + * A file's history: its earlier versions, and what can be done with them. A + * version is always reached through the file it belongs to, named by its path; + * the server decides what this person may see and do. + */ + +const pathQuery = (path) => `path=${encodeURIComponent(normalizePath(path))}`; +const versionEndpoint = (id, suffix = '') => `/api/versions/${encodeURIComponent(id)}${suffix}`; + +const send = (endpoint, method, body) => + requestJson(endpoint, { method, body: JSON.stringify(body) }); + +/** The versions of a file, newest first, what the file is now, and what may be done. */ +async function getVersions(path) { + return requestJson(`/api/versions?${pathQuery(path)}`, { method: 'GET' }); +} + +/** Where a version downloads from: a plain link, so the browser saves it as it does any file. */ +function getVersionDownloadUrl(path, id) { + return buildUrl(`${versionEndpoint(id, '/content')}?${pathQuery(path)}`); +} + +/** The text of a version, to read and never to change: `{ name, content, … }`. */ +async function getVersionText(path, id) { + return requestJson(`${versionEndpoint(id, '/text')}?${pathQuery(path)}`, { method: 'GET' }); +} + +/** Put the file back as the version had it; what it holds now becomes a version. */ +async function restoreVersion(path, id) { + return send(versionEndpoint(id, '/restore'), 'POST', { path: normalizePath(path) }); +} + +/** Take a version out as a new file in `destination`. */ +async function copyVersionTo(path, id, destination) { + return send(versionEndpoint(id, '/copy'), 'POST', { + path: normalizePath(path), + destination: normalizePath(destination), + }); +} + +/** Put a version's content over another existing file. */ +async function replaceWithVersion(path, id, target) { + return send(versionEndpoint(id, '/replace'), 'POST', { + path: normalizePath(path), + target: normalizePath(target), + }); +} + +/** Name a version, or pin it: `{ label?, pinned? }`. */ +async function updateVersion(path, id, changes) { + return send(versionEndpoint(id), 'PATCH', { path: normalizePath(path), ...changes }); +} + +/** Delete versions for good: `{ ids }`, or `{ all: true }`. */ +async function deleteVersions(path, { ids, all = false } = {}) { + return send('/api/versions/delete', 'POST', { + path: normalizePath(path), + ...(all ? { all: true } : { ids }), + }); +} + +/** + * The administrator's side: every file that has a history, wherever it is. + * + * Addressed by the history's own id rather than by a path, because the ones + * worth finding include files that no longer exist — a history whose file was + * deleted outside the application has no path left to ask about. + */ + +/** A page of the files that have versions: `{ files, total, totalBytes, zones, … }`. */ +async function getVersionedFiles({ zone, state, q, sort, limit, offset } = {}) { + const query = new URLSearchParams(); + if (zone) query.set('zone', zone); + if (state) query.set('state', state); + if (q) query.set('q', q); + if (sort) query.set('sort', sort); + if (Number.isFinite(limit)) query.set('limit', String(limit)); + if (Number.isFinite(offset) && offset > 0) query.set('offset', String(offset)); + const suffix = query.toString(); + return requestJson(`/api/versions/admin/files${suffix ? `?${suffix}` : ''}`, { method: 'GET' }); +} + +/** One history and its versions, by id. */ +async function getVersionedFile(id) { + return requestJson(`/api/versions/admin/files/${encodeURIComponent(id)}`, { method: 'GET' }); +} + +/** Delete versions of one history: `{ ids }`, or `{ all: true }`. */ +async function deleteVersionsOfFile(id, { ids, all = false } = {}) { + return send(`/api/versions/admin/files/${encodeURIComponent(id)}/delete`, 'POST', { + ...(all ? { all: true } : { ids }), + }); +} + +export { + getVersions, + getVersionDownloadUrl, + getVersionText, + restoreVersion, + copyVersionTo, + replaceWithVersion, + updateVersion, + deleteVersions, + getVersionedFiles, + getVersionedFile, + deleteVersionsOfFile, +}; diff --git a/frontend/src/components/ExplorerContextMenu.vue b/frontend/src/components/ExplorerContextMenu.vue index 7fccbd8c..090f1b0f 100644 --- a/frontend/src/components/ExplorerContextMenu.vue +++ b/frontend/src/components/ExplorerContextMenu.vue @@ -22,11 +22,13 @@ import { ShareIcon, ArchiveBoxArrowDownIcon, ArrowUpOnSquareIcon, + ClockIcon, } from '@heroicons/vue/24/outline'; import { StarIcon as StarSolid } from '@heroicons/vue/24/solid'; import { useFavoriteEditor } from '@/composables/useFavoriteEditor'; import { useTerminalStore } from '@/stores/terminal'; import { useFeaturesStore } from '@/stores/features'; +import { useVersionsPanelStore } from '@/stores/versionsPanel'; import { isTerminalExtension } from '@/config/terminal'; // Icons import { @@ -47,6 +49,7 @@ const favoritesStore = useFavoritesStore(); const { openEditorForFavorite } = useFavoriteEditor(); const terminalStore = useTerminalStore(); const featuresStore = useFeaturesStore(); +const versionsPanel = useVersionsPanelStore(); const router = useRouter(); const isOpen = ref(false); @@ -252,6 +255,25 @@ const runGetInfo = () => { infoPanel.open(primaryItem.value); }; +/** + * A file's history, where there can be one: a single file, versions switched + * on, and — through a share — a share whose owner shows it. + */ +const canShowVersions = computed( + () => + featuresStore.versionsEnabled && + contextKind.value === 'file' && + isSingleItemSelected.value && + Boolean(primaryItem.value) && + fileStore.currentPathData?.canSeeVersions !== false +); + +const runShowVersions = () => { + if (!canShowVersions.value) return; + infoPanel.close(); + versionsPanel.open(primaryItem.value); +}; + const runOpenWithEditor = () => { if (!primaryItem.value) return; const item = primaryItem.value; @@ -408,11 +430,15 @@ const menuSections = computed(() => { } const sections = []; - sections.push([ + const infoSection = [ mk('get-info', t('context.getInfo'), InfoRound, runGetInfo, { disabled: !primaryItem.value, }), - ]); + ]; + if (canShowVersions.value) { + infoSection.push(mk('versions', t('versions.menu'), ClockIcon, runShowVersions)); + } + sections.push(infoSection); // Add "Open with Editor" for files only if (contextKind.value === 'file') { diff --git a/frontend/src/components/FileObject.vue b/frontend/src/components/FileObject.vue index 39a4c8e0..e5b64d42 100644 --- a/frontend/src/components/FileObject.vue +++ b/frontend/src/components/FileObject.vue @@ -19,6 +19,9 @@ import MiddleEllipsis from '@/components/MiddleEllipsis.vue'; import { ellipses } from '@/utils/ellipses'; import { useInputMode } from '@/composables/useInputMode'; import { CheckIcon } from '@heroicons/vue/20/solid'; +import { ClockIcon } from '@heroicons/vue/24/outline'; +import { useI18n } from 'vue-i18n'; +import { useVersionsPanelStore } from '@/stores/versionsPanel'; import { useFileDragDrop } from '@/composables/useFileDragDrop'; const props = defineProps(['item', 'view']); @@ -78,6 +81,34 @@ const isCut = computed(() => const selected = computed(() => isSelected(props.item)); +/** + * The file has earlier versions, and how many. + * + * Sent with the listing when the person asked to see it and may see this + * file's history at all — a share hands out neither the history nor the fact + * that there is one unless its owner said so. Nothing is decided here: the + * mark is there when the count is. + */ +const { t } = useI18n(); +const versionsPanel = useVersionsPanelStore(); +const versionCount = computed(() => { + const count = Number(props.item?.versions?.count); + return Number.isFinite(count) && count > 0 ? count : 0; +}); +// `(key, named, plural)`, as the Versions panel calls it: the third argument of +// the other overload is a bag of options, not a bag of values. +const versionsLabel = computed(() => + versionCount.value ? t('versions.mark', { count: versionCount.value }, versionCount.value) : '' +); +/** + * Straight to the history, rather than the row's own click: it is the one thing + * the mark could mean, and the right-click route stays as it was. + */ +const openVersions = () => { + if (!versionCount.value) return; + versionsPanel.open(props.item); +}; + const showSelectionControl = computed(() => !isTouchDevice.value || selectionMode.value); const selectionButtonBaseClass = @@ -261,6 +292,19 @@ if (isTouchDevice.value) { > + @@ -316,7 +360,20 @@ if (isTouchDevice.value) { /> @@ -374,7 +431,20 @@ if (isTouchDevice.value) { />

@@ -448,12 +518,26 @@ if (isTouchDevice.value) { />

- +
{{ getKindLabel(item) }} diff --git a/frontend/src/components/InfoPanel.vue b/frontend/src/components/InfoPanel.vue index 87a11dd1..ebc0fffb 100644 --- a/frontend/src/components/InfoPanel.vue +++ b/frontend/src/components/InfoPanel.vue @@ -2,6 +2,9 @@ import { computed, onMounted, onBeforeUnmount, watch, ref } from 'vue'; import { XMarkIcon } from '@heroicons/vue/24/outline'; import { useInfoPanelStore } from '@/stores/infoPanel'; +import { useVersionsPanelStore } from '@/stores/versionsPanel'; +import { useFeaturesStore } from '@/stores/features'; +import { useFileStore } from '@/stores/fileStore'; import { formatBytes, formatDate } from '@/utils'; import { getKindLabel } from '@/utils/fileKinds'; import FileIcon from '@/icons/FileIcon.vue'; @@ -17,6 +20,24 @@ const item = computed(() => store.item); const relativePath = computed(() => store.relativePath); const { t } = useI18n(); +const featuresStore = useFeaturesStore(); +const versionsPanel = useVersionsPanelStore(); +const fileStore = useFileStore(); +// Through a share whose owner keeps the history hidden, the listing says so. +const canShowVersions = computed( + () => + featuresStore.versionsEnabled && + Boolean(item.value) && + !['directory', 'volume'].includes(item.value.kind) && + fileStore.currentPathData?.canSeeVersions !== false +); +const openVersions = () => { + const target = item.value; + if (!target) return; + store.close(); + versionsPanel.open(target); +}; + const title = computed(() => item.value?.name || t('common.details')); const kindLabel = computed(() => (item.value ? getKindLabel(item.value) : '')); @@ -267,6 +288,16 @@ onBeforeUnmount(() => {

+ +
+import { computed, ref, watch } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useRouter } from 'vue-router'; +import { useEventListener } from '@vueuse/core'; +import { + ArrowPathIcon, + EllipsisVerticalIcon, + MapPinIcon, + XMarkIcon, +} from '@heroicons/vue/24/outline'; +import ModalDialog from '@/components/ModalDialog.vue'; +import { useVersionsPanelStore } from '@/stores/versionsPanel'; +import { useNotificationsStore } from '@/stores/notifications'; +import { useFileStore } from '@/stores/fileStore'; +import { isEditableExtension } from '@/config/editor'; +import { formatBytes, formatLocalDateTime } from '@/utils'; +import { + deleteVersions, + getVersionDownloadUrl, + getVersions, + normalizePath, + restoreVersion, + updateVersion, +} from '@/api'; + +/** + * A file's history: the earlier versions its saves left, newest first, and + * what can be done with each — look at it, download it, put it back, take it + * out as a copy or over another file, name it, pin it, delete it. + * + * It sits above the preview layer, since an office editor opens it too, and its + * own dialogs sit above it. + */ + +const MAX_LABEL_LENGTH = 200; + +const store = useVersionsPanelStore(); +const notifications = useNotificationsStore(); +const fileStore = useFileStore(); +const router = useRouter(); +const { t } = useI18n(); + +const isOpen = computed(() => store.isOpen); +const filePath = computed(() => store.relativePath); +const fileName = computed(() => store.item?.name || ''); +const parentPath = computed(() => { + const segments = filePath.value.split('/').filter(Boolean); + segments.pop(); + return segments.join('/'); +}); + +const data = ref(null); +const loading = ref(false); +const loadError = ref(''); +const selected = ref([]); +const openMenu = ref(null); +const busy = ref(false); +const confirmation = ref(null); +const naming = ref(null); + +const versions = computed(() => data.value?.versions || []); +const rights = computed( + () => data.value?.rights || { see: false, download: false, restore: false, remove: false } +); +const allSelected = computed( + () => versions.value.length > 0 && selected.value.length === versions.value.length +); + +const load = async () => { + if (!isOpen.value || !filePath.value) return; + const requested = filePath.value; + loading.value = true; + loadError.value = ''; + try { + const response = await getVersions(requested); + if (requested !== filePath.value) return; + data.value = response; + const ids = new Set((response?.versions || []).map((version) => version.id)); + selected.value = selected.value.filter((id) => ids.has(id)); + } catch (error) { + if (requested !== filePath.value) return; + data.value = null; + loadError.value = + error?.statusCode === 403 + ? t('versions.notShared') + : error?.message || t('versions.loadFailed'); + } finally { + if (requested === filePath.value) loading.value = false; + } +}; + +watch( + [isOpen, filePath], + ([open]) => { + openMenu.value = null; + if (!open) return; + data.value = null; + selected.value = []; + void load(); + }, + { immediate: true } +); + +const close = () => store.close(); + +useEventListener(window, 'keydown', (event) => { + if (event.key !== 'Escape' || !isOpen.value) return; + if (confirmation.value || naming.value) return; + if (openMenu.value) { + openMenu.value = null; + return; + } + close(); +}); + +useEventListener(document, 'pointerdown', (event) => { + if (!openMenu.value) return; + if (event.target?.closest?.('[data-version-menu]')) return; + openMenu.value = null; +}); + +const SOURCES = { + editor: 'editor', + 'share-editor': 'shareEditor', + onlyoffice: 'onlyoffice', + collabora: 'collabora', + restore: 'restore', + external: 'external', +}; + +const sourceLabel = (source) => (SOURCES[source] ? t(`versions.source.${SOURCES[source]}`) : ''); + +/** Who wrote a content, as a person recognises it. */ +const authorLabel = (author) => { + if (!author) return t('versions.unknownAuthor'); + if (!author.id && author.label === 'share-link') return t('versions.shareLink'); + return author.label || t('versions.unknownAuthor'); +}; + +const describe = (entry) => + [authorLabel(entry.author), sourceLabel(entry.source), formatBytes(entry.size)] + .filter(Boolean) + .join(' · '); + +const extension = computed(() => { + const name = fileName.value; + const dot = name.lastIndexOf('.'); + return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''; +}); + +const actionsFor = (version) => { + const usable = version.available !== false; + const list = []; + // A text file opens in the editor, read only on that version. An office + // document has no reader for a version yet. + if (usable && isEditableExtension(extension.value)) { + list.push({ id: 'preview', label: t('versions.actions.preview') }); + } + if (usable && rights.value.download) { + list.push({ id: 'download', label: t('versions.actions.download') }); + } + if (usable && rights.value.restore) { + list.push({ id: 'restore', label: t('versions.actions.restore') }); + } + if (rights.value.restore) { + list.push({ id: 'rename', label: t('versions.actions.rename') }); + list.push({ + id: 'pin', + label: version.pinned ? t('versions.actions.unpin') : t('versions.actions.pin'), + }); + } + if (rights.value.remove) { + list.push({ id: 'delete', label: t('versions.actions.delete'), danger: true }); + } + return list; +}; + +const toggleMenu = (id) => { + openMenu.value = openMenu.value === id ? null : id; +}; + +const toggleSelected = (id) => { + selected.value = selected.value.includes(id) + ? selected.value.filter((candidate) => candidate !== id) + : [...selected.value, id]; +}; + +const toggleAll = () => { + selected.value = allSelected.value ? [] : versions.value.map((version) => version.id); +}; + +const notifyFailure = (error) => { + notifications.addNotification({ + type: 'error', + heading: t('versions.errors.action'), + body: error?.message || '', + }); +}; + +/** What changed on disk shows in the folder being browsed, if it is the one. */ +const refreshListing = async (folder) => { + try { + const current = normalizePath(fileStore.currentPath || ''); + if (typeof fileStore.fetchPathItems === 'function' && current === normalizePath(folder || '')) { + await fileStore.fetchPathItems(current); + } + } catch { + // The listing catches up on the next visit; the change itself succeeded. + } +}; + +const work = async (task) => { + busy.value = true; + try { + await task(); + } catch (error) { + notifyFailure(error); + } finally { + busy.value = false; + await load(); + } +}; + +const download = (version) => { + const link = document.createElement('a'); + link.href = getVersionDownloadUrl(filePath.value, version.id); + link.rel = 'noopener'; + link.download = ''; + document.body.appendChild(link); + link.click(); + link.remove(); +}; + +const preview = (version) => { + const path = filePath.value; + close(); + router.push({ name: 'VersionFileViewer', params: { versionId: version.id, path } }); +}; + +const runAction = (action, version) => { + openMenu.value = null; + switch (action.id) { + case 'preview': + preview(version); + break; + case 'download': + download(version); + break; + case 'restore': + confirmation.value = { kind: 'restore', version }; + break; + case 'rename': + naming.value = { version, value: version.label || '' }; + break; + case 'pin': + void work(async () => { + await updateVersion(filePath.value, version.id, { pinned: !version.pinned }); + notifications.addNotification({ + type: 'success', + heading: version.pinned ? t('versions.results.unpinned') : t('versions.results.pinned'), + durationMs: 3000, + }); + }); + break; + case 'delete': + confirmation.value = { kind: 'delete', ids: [version.id] }; + break; + default: + break; + } +}; + +const confirmationOpen = computed({ + get: () => Boolean(confirmation.value), + set: (value) => { + if (!value) confirmation.value = null; + }, +}); + +const confirmationTitle = computed(() => { + const request = confirmation.value; + if (!request) return ''; + if (request.kind === 'restore') return t('versions.confirm.restoreTitle'); + if (request.kind === 'deleteAll') return t('versions.confirm.deleteAllTitle'); + return t('versions.confirm.deleteTitle', { count: request.ids.length }, request.ids.length); +}); + +const confirmationMessage = computed(() => { + const request = confirmation.value; + if (!request) return ''; + if (request.kind === 'restore') { + return t('versions.confirm.restoreMessage', { + name: fileName.value, + date: formatLocalDateTime(request.version.modifiedAt), + }); + } + if (request.kind === 'deleteAll') { + return t('versions.confirm.deleteAllMessage', { name: fileName.value }); + } + return t('versions.confirm.deleteMessage', { count: request.ids.length }, request.ids.length); +}); + +const confirmationDanger = computed(() => + ['delete', 'deleteAll'].includes(confirmation.value?.kind) +); + +/** The button says what happens. */ +const confirmationButton = computed(() => { + if (confirmation.value?.kind === 'restore') return t('versions.actions.restore'); + return t('common.delete'); +}); + +const confirm = async () => { + const request = confirmation.value; + if (!request) return; + confirmation.value = null; + await work(async () => { + if (request.kind === 'restore') { + const result = await restoreVersion(filePath.value, request.version.id); + store.markRestored(); + notifications.addNotification({ + type: result?.status === 'unchanged' ? 'info' : 'success', + heading: + result?.status === 'unchanged' + ? t('versions.results.unchanged') + : t('versions.results.restored'), + durationMs: 4000, + }); + await refreshListing(parentPath.value); + } else { + const result = await deleteVersions( + filePath.value, + request.kind === 'deleteAll' ? { all: true } : { ids: request.ids } + ); + const count = Number(result?.deleted) || 0; + selected.value = []; + notifications.addNotification({ + type: 'success', + heading: t('versions.results.deleted', { count }, count), + durationMs: 4000, + }); + // The row behind the panel carries a mark saying how many versions the + // file has. Deleting them here and leaving that mark at its old number + // is the panel disagreeing with the listing it was opened from. + await refreshListing(parentPath.value); + } + }); +}; + +const namingOpen = computed({ + get: () => Boolean(naming.value), + set: (value) => { + if (!value) naming.value = null; + }, +}); + +const saveName = async () => { + const request = naming.value; + if (!request || request.value.trim().length > MAX_LABEL_LENGTH) return; + naming.value = null; + await work(async () => { + await updateVersion(filePath.value, request.version.id, { label: request.value.trim() }); + notifications.addNotification({ + type: 'success', + heading: t('versions.results.renamed'), + durationMs: 3000, + }); + }); +}; + + + + + diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 32413e1a..45b62d3c 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "Sie haben ungespeicherte Änderungen. Ohne Speichern schließen?" + "confirmCloseWithoutSaving": "Sie haben ungespeicherte Änderungen. Ohne Speichern schließen?", + "trashReadOnly": "Im Papierkorb · schreibgeschützt", + "versionReadOnly": "Frühere Version, schreibgeschützt" }, "status": { "updated": "Erfolgreich aktualisiert", @@ -319,7 +321,8 @@ "dateTaken": "Aufnahmedatum: {date}", "camera": "Kamera: {makeModel}", "lens": "Objektiv: {lens}", - "duration": "Dauer: {seconds}s" + "duration": "Dauer: {seconds}s", + "versions": "Versionen" }, "auth": { "preparing": "Ihr Explorer wird vorbereitet…", @@ -392,7 +395,8 @@ "security": "Sicherheit", "accessControl": "Zugriffskontrolle", "adminUsers": "Benutzerverwaltung", - "trash": "Papierkorb und Versionen" + "trash": "Papierkorb und Versionen", + "fileVersions": "Dateiversionen" }, "about": { "subtitle": "Build-Informationen für diese Anwendung anzeigen.", @@ -442,7 +446,9 @@ "months": "Monate", "skipHome": "Startseite überspringen", "skipHomeHelp": "Leitet beim Besuch der Startseite automatisch zur ersten Volume weiter. Wenn nicht gesetzt, wird die Serverkonfiguration verwendet.", - "useEnvSetting": "Servereinstellung verwenden" + "useEnvSetting": "Servereinstellung verwenden", + "showVersionMarks": "Dateien mit Versionen kennzeichnen", + "showVersionMarksHelp": "Ein kleines Zeichen in der Liste bei Dateien mit früheren Versionen, mit deren Anzahl. Ein Klick öffnet den Verlauf." }, "thumbs": { "subtitle": "Vorschau-Miniaturen für Bilder und Videos anpassen.", @@ -615,6 +621,53 @@ "sharedSpace": "Versionen und Papierkorb teilen sich den reservierten Bereich jedes Volumes. Wird er knapp, gehen zuerst ältere Versionen, dann Papierkorbelemente, dann die letzte Version jeder Datei und zuletzt angeheftete Versionen.", "environmentNote": "Standardwerte stammen aus VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE und VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Dateiversionen", + "intro": "Alle Dateien mit früheren Versionen, wo immer sie liegen, und wie viel Platz diese belegen. Hier erscheinen Pfade aus allen Bereichen, persönliche Ordner eingeschlossen — deshalb ist diese Seite Administratoren vorbehalten.", + "search": "Pfad enthält", + "searchPlaceholder": "Teil eines Namens oder Ordners", + "zone": "Bereich", + "anyZone": "Alle Bereiche", + "state": "Zustand", + "anyState": "Alle", + "sort": "Sortieren nach", + "sortBytes": "Belegter Platz", + "sortCount": "Anzahl der Versionen", + "sortNewest": "Neueste Version", + "sortPath": "Pfad", + "summary": "{files} Dateien, {versions} Versionen, {size}", + "file": "Datei", + "count": "Versionen", + "size": "Größe", + "newest": "Neueste", + "states": { + "live": "Vorhanden", + "trashed": "Im Papierkorb", + "orphaned": "Verschwunden" + }, + "zoneKinds": { + "volume": "Volume {name}", + "personal": "Persönlicher Ordner {name}", + "user-volume": "Zugewiesenes Volume {name}" + }, + "zoneUnknown": "Unbekannter Bereich", + "pinned": "Angeheftet", + "unavailable": "Volume nicht verfügbar", + "deleteAll": "Verlauf löschen", + "deleteSelected": "{count} Version löschen | {count} Versionen löschen", + "deleteForGood": "Endgültig löschen", + "confirmAllTitle": "Alle Versionen von {name} löschen?", + "confirmSomeTitle": "{count} Version löschen? | {count} Versionen löschen?", + "confirmMessage": "Dieser Inhalt wird von der Festplatte entfernt. Die Datei selbst bleibt unberührt, und nichts davon lässt sich rückgängig machen.", + "none": "Keine Datei hat frühere Versionen.", + "loadFailed": "Die Liste konnte nicht gelesen werden.", + "detailFailed": "Dieser Verlauf konnte nicht gelesen werden.", + "deleteFailed": "Die Versionen konnten nicht gelöscht werden.", + "previous": "Zurück", + "next": "Weiter", + "range": "{from} bis {to} von {total}", + "deleteSelectedNone": "Angehakte Versionen löschen" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "restored": "{count} Freigabelink wiederhergestellt | {count} Freigabelinks wiederhergestellt", "dropped": "{count} Freigabelink gelöscht | {count} Freigabelinks gelöscht" } + }, + "versions": { + "title": "Versionen", + "menu": "Versionen", + "aria": "Dateiversionen", + "current": "Aktuelle Version", + "empty": "Noch keine frühere Version. Jedes Speichern behält, was es ersetzt.", + "disabled": "Dateiversionen sind ausgeschaltet: Beim Speichern werden keine neuen mehr behalten. Die folgenden bleiben bis zu ihrem Ablauf.", + "loadFailed": "Die Versionen konnten nicht geladen werden.", + "notShared": "Der Verlauf dieser Datei ist nicht für Sie freigegeben.", + "unavailable": "Ihr Inhalt fehlt auf der Festplatte.", + "unknownAuthor": "Unbekannter Autor", + "shareLink": "Jemand mit dem Link", + "pinned": "Angeheftet", + "aside": "Beiseitegelegt", + "asideHelp": "Von einem Editor gespeichert, der vor einer Wiederherstellung geöffnet wurde: hier behalten, statt die Wiederherstellung rückgängig zu machen.", + "total": "{count} Version, {size} | {count} Versionen, {size}", + "source": { + "editor": "Texteditor", + "shareEditor": "Editor über eine Freigabe", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Wiederhergestellte Version", + "external": "Außerhalb der App geändert" + }, + "actions": { + "menu": "Aktionen für die Version", + "select": "Diese Version auswählen", + "selectAll": "Alle auswählen", + "deleteSelected": "Auswahl löschen ({count})", + "deleteAll": "Alle löschen", + "preview": "Schreibgeschützt öffnen", + "download": "Herunterladen", + "restore": "Wiederherstellen", + "rename": "Benennen…", + "pin": "Anheften", + "unpin": "Lösen", + "delete": "Löschen" + }, + "confirm": { + "restoreTitle": "Diese Version wiederherstellen?", + "restoreMessage": "„{name}“ erhält seinen Inhalt vom {date} zurück. Der aktuelle Inhalt wird als Version behalten.", + "deleteTitle": "Diese Version löschen? | {count} Versionen löschen?", + "deleteMessage": "Diese Version wird endgültig gelöscht. | Diese {count} Versionen werden endgültig gelöscht.", + "deleteAllTitle": "Alle Versionen löschen?", + "deleteAllMessage": "Alle früheren Versionen von „{name}“ werden endgültig gelöscht, angeheftete eingeschlossen. Die Datei selbst bleibt." + }, + "rename": { + "title": "Diese Version benennen", + "placeholder": "Zum Beispiel: an den Kunden gesendet", + "help": "Mit einem Namen ist eine Version leicht zu finden. Heften Sie sie an, um sie von der automatischen Bereinigung auszunehmen." + }, + "results": { + "restored": "Version wiederhergestellt", + "unchanged": "Die Datei hat bereits diesen Inhalt", + "deleted": "{count} Version gelöscht | {count} Versionen gelöscht", + "renamed": "Version benannt", + "pinned": "Version angeheftet: Die automatische Bereinigung behält sie", + "unpinned": "Version gelöst" + }, + "errors": { + "action": "Die Aktion für diese Version ist fehlgeschlagen" + }, + "mark": "{count} frühere Version | {count} frühere Versionen" } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 8c6a7291..358f8398 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "You have unsaved changes. Close without saving?" + "confirmCloseWithoutSaving": "You have unsaved changes. Close without saving?", + "trashReadOnly": "In the trash · read only", + "versionReadOnly": "Earlier version, read-only" }, "status": { "updated": "Updated successfully", @@ -319,7 +321,8 @@ "dateTaken": "Date Taken: {date}", "camera": "Camera: {makeModel}", "lens": "Lens: {lens}", - "duration": "Duration: {seconds}s" + "duration": "Duration: {seconds}s", + "versions": "Versions" }, "auth": { "preparing": "Preparing your explorer…", @@ -392,7 +395,8 @@ "security": "Security", "accessControl": "Access Control", "adminUsers": "User Management", - "trash": "Trash and versions" + "trash": "Trash and versions", + "fileVersions": "File versions" }, "about": { "subtitle": "View build information for this application.", @@ -442,7 +446,9 @@ "months": "Months", "skipHome": "Skip home page", "skipHomeHelp": "Automatically redirect to the first volume when visiting the home page. If not set, follows the server configuration.", - "useEnvSetting": "Use server setting" + "useEnvSetting": "Use server setting", + "showVersionMarks": "Mark files that have versions", + "showVersionMarksHelp": "A small mark in the listing on any file with earlier versions, with how many. Click it to open the history." }, "thumbs": { "subtitle": "Customize preview thumbnails for images and videos.", @@ -615,6 +621,53 @@ "sharedSpace": "Versions and the trash share each volume's reserved space. When it runs short, older versions go first, then trash items, then each file's latest version, and pinned versions last.", "environmentNote": "Defaults come from VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE and VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "File versions", + "intro": "Every file that has earlier versions, wherever it is, and what they take up. Paths from every space appear here, personal folders included — which is why this page is for administrators.", + "search": "Path contains", + "searchPlaceholder": "Part of a name or folder", + "zone": "Space", + "anyZone": "Any space", + "state": "Status", + "anyState": "Any", + "sort": "Sort by", + "sortBytes": "Space used", + "sortCount": "Number of versions", + "sortNewest": "Most recent version", + "sortPath": "Path", + "summary": "{files} files, {versions} versions, {size}", + "file": "File", + "count": "Versions", + "size": "Size", + "newest": "Most recent", + "states": { + "live": "Present", + "trashed": "In the trash", + "orphaned": "Gone" + }, + "zoneKinds": { + "volume": "Volume {name}", + "personal": "Personal folder {name}", + "user-volume": "Assigned volume {name}" + }, + "zoneUnknown": "Unknown space", + "pinned": "Pinned", + "unavailable": "Volume unavailable", + "deleteAll": "Delete the history", + "deleteSelected": "Delete {count} version | Delete {count} versions", + "deleteForGood": "Delete for good", + "confirmAllTitle": "Delete every version of {name}?", + "confirmSomeTitle": "Delete {count} version? | Delete {count} versions?", + "confirmMessage": "That content is removed from the disk. The file itself is not touched, and nothing here can be undone.", + "none": "No file has earlier versions.", + "loadFailed": "The list could not be read.", + "detailFailed": "This history could not be read.", + "deleteFailed": "The versions could not be deleted.", + "previous": "Previous", + "next": "Next", + "range": "{from} to {to} of {total}", + "deleteSelectedNone": "Delete the versions you tick" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "restored": "{count} share link brought back | {count} share links brought back", "dropped": "{count} share link deleted | {count} share links deleted" } + }, + "versions": { + "title": "Versions", + "menu": "Versions", + "aria": "File versions", + "current": "Current version", + "empty": "No earlier version yet. Each save keeps what it replaces.", + "disabled": "File versions are switched off: saves no longer keep new ones. Those below stay until they expire.", + "loadFailed": "The versions could not be loaded.", + "notShared": "The history of this file is not shared with you.", + "unavailable": "Its content is missing from the disk.", + "unknownAuthor": "Unknown author", + "shareLink": "Someone with the link", + "pinned": "Pinned", + "aside": "Set aside", + "asideHelp": "Saved by an editor opened before a restore: kept here rather than undoing the restore.", + "total": "{count} version, {size} | {count} versions, {size}", + "source": { + "editor": "Text editor", + "shareEditor": "Editor through a share", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Restored version", + "external": "Changed outside the app" + }, + "actions": { + "menu": "Version actions", + "select": "Select this version", + "selectAll": "Select all", + "deleteSelected": "Delete selected ({count})", + "deleteAll": "Delete all", + "preview": "Open read-only", + "download": "Download", + "restore": "Restore", + "rename": "Name…", + "pin": "Pin", + "unpin": "Unpin", + "delete": "Delete" + }, + "confirm": { + "restoreTitle": "Restore this version?", + "restoreMessage": "\"{name}\" goes back to its content of {date}. What it holds now is kept as a version.", + "deleteTitle": "Delete this version? | Delete {count} versions?", + "deleteMessage": "This version is deleted for good. | These {count} versions are deleted for good.", + "deleteAllTitle": "Delete all versions?", + "deleteAllMessage": "Every earlier version of \"{name}\" is deleted for good, pinned ones included. The file itself stays." + }, + "rename": { + "title": "Name this version", + "placeholder": "For example: sent to the client", + "help": "A name makes a version easy to find. Pin it to keep it out of the automatic cleanup." + }, + "results": { + "restored": "Version restored", + "unchanged": "The file already has this content", + "deleted": "{count} version deleted | {count} versions deleted", + "renamed": "Version named", + "pinned": "Version pinned: the automatic cleanup keeps it", + "unpinned": "Version unpinned" + }, + "errors": { + "action": "The action on this version failed" + }, + "mark": "{count} earlier version | {count} earlier versions" } } diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 768b8a04..68c6eb15 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "Tienes cambios sin guardar. ¿Cerrar sin guardar?" + "confirmCloseWithoutSaving": "Tienes cambios sin guardar. ¿Cerrar sin guardar?", + "trashReadOnly": "En la papelera · solo lectura", + "versionReadOnly": "Versión anterior, solo lectura" }, "status": { "updated": "Actualizado correctamente", @@ -319,7 +321,8 @@ "dateTaken": "Fecha de captura: {date}", "camera": "Cámara: {makeModel}", "lens": "Lente: {lens}", - "duration": "Duración: {seconds}s" + "duration": "Duración: {seconds}s", + "versions": "Versiones" }, "auth": { "preparing": "Preparando tu explorador…", @@ -392,7 +395,8 @@ "security": "Seguridad", "accessControl": "Control de acceso", "adminUsers": "Gestión de usuarios", - "trash": "Papelera y versiones" + "trash": "Papelera y versiones", + "fileVersions": "Versiones de archivos" }, "about": { "subtitle": "Ver la información de compilación de esta aplicación.", @@ -442,7 +446,9 @@ "months": "Meses", "skipHome": "Saltar inicio", "skipHomeHelp": "Redirige automáticamente a la primera unidad al visitar la pantalla de inicio. Si no se establece, usa la configuración del servidor.", - "useEnvSetting": "Usar configuración del servidor" + "useEnvSetting": "Usar configuración del servidor", + "showVersionMarks": "Marcar los archivos con versiones", + "showVersionMarksHelp": "Una pequeña marca en la lista sobre los archivos con versiones anteriores, con su número. Haga clic para abrir el historial." }, "thumbs": { "subtitle": "Personaliza las miniaturas de vista previa para imágenes y vídeos.", @@ -615,6 +621,53 @@ "sharedSpace": "Las versiones y la papelera comparten el espacio reservado de cada volumen. Cuando falta, primero se van las versiones antiguas, luego los elementos de la papelera, luego la última versión de cada archivo y, por último, las versiones fijadas.", "environmentNote": "Los valores predeterminados vienen de VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE y VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Versiones de archivos", + "intro": "Todos los archivos con versiones anteriores, estén donde estén, y el espacio que ocupan. Aquí aparecen rutas de todos los espacios, incluidas las carpetas personales: por eso esta página es solo para administradores.", + "search": "La ruta contiene", + "searchPlaceholder": "Parte de un nombre o de una carpeta", + "zone": "Espacio", + "anyZone": "Todos los espacios", + "state": "Estado", + "anyState": "Todos", + "sort": "Ordenar por", + "sortBytes": "Espacio ocupado", + "sortCount": "Número de versiones", + "sortNewest": "Versión más reciente", + "sortPath": "Ruta", + "summary": "{files} archivos, {versions} versiones, {size}", + "file": "Archivo", + "count": "Versiones", + "size": "Tamaño", + "newest": "Más reciente", + "states": { + "live": "Presente", + "trashed": "En la papelera", + "orphaned": "Desaparecido" + }, + "zoneKinds": { + "volume": "Volumen {name}", + "personal": "Carpeta personal {name}", + "user-volume": "Volumen asignado {name}" + }, + "zoneUnknown": "Espacio desconocido", + "pinned": "Fijada", + "unavailable": "Volumen no disponible", + "deleteAll": "Eliminar el historial", + "deleteSelected": "Eliminar {count} versión | Eliminar {count} versiones", + "deleteForGood": "Eliminar definitivamente", + "confirmAllTitle": "¿Eliminar todas las versiones de {name}?", + "confirmSomeTitle": "¿Eliminar {count} versión? | ¿Eliminar {count} versiones?", + "confirmMessage": "Ese contenido se borra del disco. El archivo en sí no se toca, y nada de esto se puede deshacer.", + "none": "Ningún archivo tiene versiones anteriores.", + "loadFailed": "No se pudo leer la lista.", + "detailFailed": "No se pudo leer este historial.", + "deleteFailed": "No se pudieron eliminar las versiones.", + "previous": "Anterior", + "next": "Siguiente", + "range": "{from} a {to} de {total}", + "deleteSelectedNone": "Eliminar las versiones marcadas" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "restored": "{count} enlace compartido recuperado | {count} enlaces compartidos recuperados", "dropped": "{count} enlace compartido eliminado | {count} enlaces compartidos eliminados" } + }, + "versions": { + "title": "Versiones", + "menu": "Versiones", + "aria": "Versiones del archivo", + "current": "Versión actual", + "empty": "Aún no hay versiones anteriores. Cada guardado conserva lo que reemplaza.", + "disabled": "Las versiones de archivos están desactivadas: los guardados ya no conservan nuevas. Las de abajo permanecen hasta que caduquen.", + "loadFailed": "No se pudieron cargar las versiones.", + "notShared": "El historial de este archivo no está compartido contigo.", + "unavailable": "Su contenido falta en el disco.", + "unknownAuthor": "Autor desconocido", + "shareLink": "Alguien con el enlace", + "pinned": "Fijada", + "aside": "Apartada", + "asideHelp": "Guardada por un editor abierto antes de una restauración: se conserva aquí en lugar de deshacer la restauración.", + "total": "{count} versión, {size} | {count} versiones, {size}", + "source": { + "editor": "Editor de texto", + "shareEditor": "Editor a través de un enlace compartido", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Versión restaurada", + "external": "Modificado fuera de la aplicación" + }, + "actions": { + "menu": "Acciones de la versión", + "select": "Seleccionar esta versión", + "selectAll": "Seleccionar todo", + "deleteSelected": "Eliminar la selección ({count})", + "deleteAll": "Eliminar todo", + "preview": "Abrir en solo lectura", + "download": "Descargar", + "restore": "Restaurar", + "rename": "Nombrar…", + "pin": "Fijar", + "unpin": "Dejar de fijar", + "delete": "Eliminar" + }, + "confirm": { + "restoreTitle": "¿Restaurar esta versión?", + "restoreMessage": "«{name}» recupera su contenido del {date}. Lo que contiene ahora se conserva como versión.", + "deleteTitle": "¿Eliminar esta versión? | ¿Eliminar {count} versiones?", + "deleteMessage": "Esta versión se elimina definitivamente. | Estas {count} versiones se eliminan definitivamente.", + "deleteAllTitle": "¿Eliminar todas las versiones?", + "deleteAllMessage": "Todas las versiones anteriores de «{name}» se eliminan definitivamente, incluidas las fijadas. El archivo en sí se mantiene." + }, + "rename": { + "title": "Nombrar esta versión", + "placeholder": "Por ejemplo: enviada al cliente", + "help": "Un nombre facilita encontrar una versión. Fíjala para excluirla de la limpieza automática." + }, + "results": { + "restored": "Versión restaurada", + "unchanged": "El archivo ya tiene este contenido", + "deleted": "{count} versión eliminada | {count} versiones eliminadas", + "renamed": "Versión nombrada", + "pinned": "Versión fijada: la limpieza automática la conserva", + "unpinned": "Versión desfijada" + }, + "errors": { + "action": "La acción sobre esta versión falló" + }, + "mark": "{count} versión anterior | {count} versiones anteriores" } } diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 6411cc89..19f40c75 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "Vous avez des modifications non enregistrées. Fermer sans enregistrer ?" + "confirmCloseWithoutSaving": "Vous avez des modifications non enregistrées. Fermer sans enregistrer ?", + "trashReadOnly": "Dans la corbeille · lecture seule", + "versionReadOnly": "Version antérieure, lecture seule" }, "status": { "updated": "Mis à jour avec succès", @@ -319,7 +321,8 @@ "dateTaken": "Date de prise : {date}", "camera": "Appareil photo : {makeModel}", "lens": "Objectif : {lens}", - "duration": "Durée : {seconds}s" + "duration": "Durée : {seconds}s", + "versions": "Versions" }, "auth": { "preparing": "Préparation de votre explorateur…", @@ -392,7 +395,8 @@ "security": "Sécurité", "accessControl": "Contrôle d'accès", "adminUsers": "Gestion des utilisateurs", - "trash": "Corbeille et versions" + "trash": "Corbeille et versions", + "fileVersions": "Versions de fichiers" }, "about": { "subtitle": "Afficher les informations de build de cette application.", @@ -442,7 +446,9 @@ "months": "Mois", "skipHome": "Passer l’accueil", "skipHomeHelp": "Redirige automatiquement vers le premier volume depuis l’accueil. Si non défini, utilise la configuration serveur.", - "useEnvSetting": "Utiliser la configuration serveur" + "useEnvSetting": "Utiliser la configuration serveur", + "showVersionMarks": "Signaler les fichiers qui ont des versions", + "showVersionMarksHelp": "Une petite marque dans la liste sur les fichiers qui ont des versions antérieures, avec leur nombre. Cliquez dessus pour ouvrir l’historique." }, "thumbs": { "subtitle": "Personnalisez les vignettes d'aperçu pour les images et les vidéos.", @@ -615,6 +621,53 @@ "sharedSpace": "Les versions et la corbeille partagent l'espace réservé de chaque volume. Quand il manque, les versions anciennes partent d'abord, puis les éléments de la corbeille, puis la dernière version de chaque fichier, et les versions épinglées en dernier.", "environmentNote": "Les valeurs par défaut viennent de VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE et VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Versions de fichiers", + "intro": "Tous les fichiers qui ont des versions antérieures, où qu’ils soient, et la place qu’elles occupent. Les chemins de tous les espaces y figurent, dossiers personnels compris — c’est pourquoi cette page est réservée aux administrateurs.", + "search": "Le chemin contient", + "searchPlaceholder": "Une partie d’un nom ou d’un dossier", + "zone": "Espace", + "anyZone": "Tous les espaces", + "state": "État", + "anyState": "Tous", + "sort": "Trier par", + "sortBytes": "Place occupée", + "sortCount": "Nombre de versions", + "sortNewest": "Version la plus récente", + "sortPath": "Chemin", + "summary": "{files} fichiers, {versions} versions, {size}", + "file": "Fichier", + "count": "Versions", + "size": "Taille", + "newest": "La plus récente", + "states": { + "live": "Présent", + "trashed": "À la corbeille", + "orphaned": "Disparu" + }, + "zoneKinds": { + "volume": "Volume {name}", + "personal": "Dossier personnel {name}", + "user-volume": "Volume attribué {name}" + }, + "zoneUnknown": "Espace inconnu", + "pinned": "Épinglée", + "unavailable": "Volume indisponible", + "deleteAll": "Supprimer l’historique", + "deleteSelected": "Supprimer {count} version | Supprimer {count} versions", + "deleteForGood": "Supprimer définitivement", + "confirmAllTitle": "Supprimer toutes les versions de {name} ?", + "confirmSomeTitle": "Supprimer {count} version ? | Supprimer {count} versions ?", + "confirmMessage": "Ce contenu est effacé du disque. Le fichier lui-même n’est pas touché, et rien ici ne peut être annulé.", + "none": "Aucun fichier n’a de version antérieure.", + "loadFailed": "La liste n’a pas pu être lue.", + "detailFailed": "Cet historique n’a pas pu être lu.", + "deleteFailed": "Les versions n’ont pas pu être supprimées.", + "previous": "Précédent", + "next": "Suivant", + "range": "{from} à {to} sur {total}", + "deleteSelectedNone": "Supprimer les versions cochées" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "restored": "{count} lien de partage rétabli | {count} liens de partage rétablis", "dropped": "{count} lien de partage supprimé | {count} liens de partage supprimés" } + }, + "versions": { + "title": "Versions", + "menu": "Versions", + "aria": "Versions du fichier", + "current": "Version actuelle", + "empty": "Aucune version antérieure pour l'instant. Chaque enregistrement garde ce qu'il remplace.", + "disabled": "Les versions de fichiers sont désactivées : les enregistrements n'en gardent plus de nouvelles. Celles ci-dessous restent jusqu'à leur expiration.", + "loadFailed": "Impossible de charger les versions.", + "notShared": "L'historique de ce fichier ne vous est pas partagé.", + "unavailable": "Son contenu est introuvable sur le disque.", + "unknownAuthor": "Auteur inconnu", + "shareLink": "Une personne disposant du lien", + "pinned": "Épinglée", + "aside": "Mise de côté", + "asideHelp": "Enregistrée par un éditeur ouvert avant une restauration : gardée ici plutôt que d'annuler la restauration.", + "total": "{count} version, {size} | {count} versions, {size}", + "source": { + "editor": "Éditeur de texte", + "shareEditor": "Éditeur via un partage", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Version restaurée", + "external": "Modifié hors de l'application" + }, + "actions": { + "menu": "Actions sur la version", + "select": "Sélectionner cette version", + "selectAll": "Tout sélectionner", + "deleteSelected": "Supprimer la sélection ({count})", + "deleteAll": "Tout supprimer", + "preview": "Ouvrir en lecture seule", + "download": "Télécharger", + "restore": "Restaurer", + "rename": "Nommer…", + "pin": "Épingler", + "unpin": "Désépingler", + "delete": "Supprimer" + }, + "confirm": { + "restoreTitle": "Restaurer cette version ?", + "restoreMessage": "« {name} » retrouve son contenu du {date}. Son contenu actuel est gardé comme version.", + "deleteTitle": "Supprimer cette version ? | Supprimer {count} versions ?", + "deleteMessage": "Cette version est supprimée définitivement. | Ces {count} versions sont supprimées définitivement.", + "deleteAllTitle": "Supprimer toutes les versions ?", + "deleteAllMessage": "Toutes les versions antérieures de « {name} » sont supprimées définitivement, épinglées comprises. Le fichier lui-même reste." + }, + "rename": { + "title": "Nommer cette version", + "placeholder": "Par exemple : envoyée au client", + "help": "Un nom permet de retrouver une version. Épinglez-la pour la protéger du nettoyage automatique." + }, + "results": { + "restored": "Version restaurée", + "unchanged": "Le fichier a déjà ce contenu", + "deleted": "{count} version supprimée | {count} versions supprimées", + "renamed": "Version nommée", + "pinned": "Version épinglée : le nettoyage automatique la garde", + "unpinned": "Version désépinglée" + }, + "errors": { + "action": "L'action sur cette version a échoué" + }, + "mark": "{count} version antérieure | {count} versions antérieures" } } diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index cba1c211..8f6614af 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "आपके पास बिना सहेजे परिवर्तन हैं। बिना सहेजे बंद करें?" + "confirmCloseWithoutSaving": "आपके पास बिना सहेजे परिवर्तन हैं। बिना सहेजे बंद करें?", + "trashReadOnly": "रीसायकल बिन में · केवल पढ़ने के लिए", + "versionReadOnly": "पिछला संस्करण, केवल पढ़ने के लिए" }, "status": { "updated": "सफलतापूर्वक अपडेट किया गया", @@ -319,7 +321,8 @@ "dateTaken": "खिंचने की तिथि: {date}", "camera": "कैमरा: {makeModel}", "lens": "लेंस: {lens}", - "duration": "अवधि: {seconds}s" + "duration": "अवधि: {seconds}s", + "versions": "संस्करण" }, "auth": { "preparing": "आपका एक्सप्लोरर तैयार किया जा रहा है…", @@ -392,7 +395,8 @@ "security": "सुरक्षा", "accessControl": "एक्सेस कंट्रोल", "adminUsers": "उपयोगकर्ता प्रबंधन", - "trash": "रीसायकल बिन और संस्करण" + "trash": "रीसायकल बिन और संस्करण", + "fileVersions": "फ़ाइल संस्करण" }, "about": { "subtitle": "इस एप्लिकेशन के बिल्ड संबंधी जानकारी देखें।", @@ -442,7 +446,9 @@ "months": "महीने", "skipHome": "होम पेज छोड़ें", "skipHomeHelp": "होम पेज पर आने पर पहले वॉल्यूम पर स्वचालित रूप से रीडायरेक्ट करें। यदि सेट नहीं है, तो सर्वर कॉन्फ़िगरेशन का पालन करें।", - "useEnvSetting": "सर्वर सेटिंग का उपयोग करें" + "useEnvSetting": "सर्वर सेटिंग का उपयोग करें", + "showVersionMarks": "संस्करण वाली फ़ाइलों पर निशान लगाएँ", + "showVersionMarksHelp": "सूची में उन फ़ाइलों पर एक छोटा निशान जिनके पुराने संस्करण हैं, उनकी संख्या के साथ। इतिहास खोलने के लिए उस पर क्लिक करें।" }, "thumbs": { "subtitle": "छवियों और वीडियो के लिए पूर्वावलोकन थंबनेल अनुकूलित करें।", @@ -615,6 +621,53 @@ "sharedSpace": "संस्करण और रीसायकल बिन हर वॉल्यूम का आरक्षित स्थान साझा करते हैं। जगह कम पड़ने पर पहले पुराने संस्करण हटते हैं, फिर रीसायकल बिन के आइटम, फिर हर फ़ाइल का नवीनतम संस्करण, और अंत में पिन किए गए संस्करण।", "environmentNote": "डिफ़ॉल्ट मान VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE और VERSIONS_SESSION_CHECKPOINT_MINUTES से आते हैं।" } + }, + "fileVersions": { + "title": "फ़ाइल संस्करण", + "intro": "हर वह फ़ाइल जिसके पुराने संस्करण हैं, चाहे कहीं भी हो, और वे कितनी जगह लेते हैं। यहाँ हर स्थान के पथ दिखते हैं, निजी फ़ोल्डर सहित — इसीलिए यह पृष्ठ केवल प्रशासकों के लिए है।", + "search": "पथ में है", + "searchPlaceholder": "नाम या फ़ोल्डर का कोई हिस्सा", + "zone": "स्थान", + "anyZone": "सभी स्थान", + "state": "स्थिति", + "anyState": "सभी", + "sort": "इसके अनुसार क्रम", + "sortBytes": "घेरी गई जगह", + "sortCount": "संस्करणों की संख्या", + "sortNewest": "सबसे नया संस्करण", + "sortPath": "पथ", + "summary": "{files} फ़ाइलें, {versions} संस्करण, {size}", + "file": "फ़ाइल", + "count": "संस्करण", + "size": "आकार", + "newest": "सबसे नया", + "states": { + "live": "मौजूद", + "trashed": "रद्दी में", + "orphaned": "गायब" + }, + "zoneKinds": { + "volume": "वॉल्यूम {name}", + "personal": "निजी फ़ोल्डर {name}", + "user-volume": "सौंपा गया वॉल्यूम {name}" + }, + "zoneUnknown": "अज्ञात स्थान", + "pinned": "पिन किया", + "unavailable": "वॉल्यूम उपलब्ध नहीं", + "deleteAll": "इतिहास हटाएँ", + "deleteSelected": "{count} संस्करण हटाएँ | {count} संस्करण हटाएँ", + "deleteForGood": "हमेशा के लिए हटाएँ", + "confirmAllTitle": "{name} के सभी संस्करण हटाएँ?", + "confirmSomeTitle": "{count} संस्करण हटाएँ? | {count} संस्करण हटाएँ?", + "confirmMessage": "यह सामग्री डिस्क से मिटा दी जाती है। फ़ाइल स्वयं अछूती रहती है, और इसे पूर्ववत नहीं किया जा सकता।", + "none": "किसी फ़ाइल का पुराना संस्करण नहीं है।", + "loadFailed": "सूची पढ़ी नहीं जा सकी।", + "detailFailed": "यह इतिहास पढ़ा नहीं जा सका।", + "deleteFailed": "संस्करण हटाए नहीं जा सके।", + "previous": "पिछला", + "next": "अगला", + "range": "{total} में से {from} से {to}", + "deleteSelectedNone": "चुने गए संस्करण हटाएँ" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "restored": "{count} साझा लिंक वापस लाया गया | {count} साझा लिंक वापस लाए गए", "dropped": "{count} साझा लिंक हटाया गया | {count} साझा लिंक हटाए गए" } + }, + "versions": { + "title": "संस्करण", + "menu": "संस्करण", + "aria": "फ़ाइल के संस्करण", + "current": "वर्तमान संस्करण", + "empty": "अभी कोई पिछला संस्करण नहीं है। हर बार सहेजने पर जो बदला जाता है, वह रखा जाता है।", + "disabled": "फ़ाइल संस्करण बंद हैं: सहेजने पर अब नए संस्करण नहीं रखे जाते। नीचे वाले अपनी अवधि पूरी होने तक रहेंगे।", + "loadFailed": "संस्करण लोड नहीं हो सके।", + "notShared": "इस फ़ाइल का इतिहास आपके साथ साझा नहीं है।", + "unavailable": "इसकी सामग्री डिस्क पर नहीं मिली।", + "unknownAuthor": "अज्ञात लेखक", + "shareLink": "लिंक वाला कोई व्यक्ति", + "pinned": "पिन किया गया", + "aside": "अलग रखा गया", + "asideHelp": "पुनर्स्थापना से पहले खुले संपादक ने सहेजा: पुनर्स्थापना को पलटने के बजाय यहाँ रखा गया।", + "total": "{count} संस्करण, {size} | {count} संस्करण, {size}", + "source": { + "editor": "टेक्स्ट संपादक", + "shareEditor": "साझाकरण के ज़रिए संपादक", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "पुनर्स्थापित संस्करण", + "external": "ऐप के बाहर बदला गया" + }, + "actions": { + "menu": "संस्करण की क्रियाएँ", + "select": "यह संस्करण चुनें", + "selectAll": "सभी चुनें", + "deleteSelected": "चुने हुए हटाएँ ({count})", + "deleteAll": "सभी हटाएँ", + "preview": "केवल पढ़ने के लिए खोलें", + "download": "डाउनलोड करें", + "restore": "पुनर्स्थापित करें", + "rename": "नाम दें…", + "pin": "पिन करें", + "unpin": "पिन हटाएँ", + "delete": "हटाएँ" + }, + "confirm": { + "restoreTitle": "यह संस्करण पुनर्स्थापित करें?", + "restoreMessage": "\"{name}\" अपनी {date} की सामग्री पर लौट आएगी। अभी की सामग्री एक संस्करण के रूप में रखी जाएगी।", + "deleteTitle": "यह संस्करण हटाएँ? | {count} संस्करण हटाएँ?", + "deleteMessage": "यह संस्करण स्थायी रूप से हटा दिया जाएगा। | ये {count} संस्करण स्थायी रूप से हटा दिए जाएँगे।", + "deleteAllTitle": "सभी संस्करण हटाएँ?", + "deleteAllMessage": "\"{name}\" के सभी पिछले संस्करण स्थायी रूप से हटा दिए जाएँगे, पिन किए गए भी। फ़ाइल स्वयं बनी रहेगी।" + }, + "rename": { + "title": "इस संस्करण को नाम दें", + "placeholder": "उदाहरण: ग्राहक को भेजा गया", + "help": "नाम से संस्करण ढूँढना आसान होता है। इसे स्वचालित सफ़ाई से बचाने के लिए पिन करें।" + }, + "results": { + "restored": "संस्करण पुनर्स्थापित हुआ", + "unchanged": "फ़ाइल में पहले से यही सामग्री है", + "deleted": "{count} संस्करण हटाया गया | {count} संस्करण हटाए गए", + "renamed": "संस्करण को नाम दिया गया", + "pinned": "संस्करण पिन हुआ: स्वचालित सफ़ाई इसे रखेगी", + "unpinned": "संस्करण का पिन हटाया गया" + }, + "errors": { + "action": "इस संस्करण पर क्रिया विफल रही" + }, + "mark": "{count} पुराना संस्करण | {count} पुराने संस्करण" } } diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 3a3edcea..3ea8b631 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "Hai modifiche non salvate. Chiudere senza salvare?" + "confirmCloseWithoutSaving": "Hai modifiche non salvate. Chiudere senza salvare?", + "trashReadOnly": "Nel cestino · sola lettura", + "versionReadOnly": "Versione precedente, sola lettura" }, "status": { "updated": "Aggiornato con successo", @@ -319,7 +321,8 @@ "dateTaken": "Data scatto: {date}", "camera": "Fotocamera: {makeModel}", "lens": "Obiettivo: {lens}", - "duration": "Durata: {seconds}s" + "duration": "Durata: {seconds}s", + "versions": "Versioni" }, "auth": { "preparing": "Preparazione del tuo explorer…", @@ -392,7 +395,8 @@ "security": "Sicurezza", "accessControl": "Controllo accessi", "adminUsers": "Gestione utenti", - "trash": "Cestino e versioni" + "trash": "Cestino e versioni", + "fileVersions": "Versioni dei file" }, "about": { "subtitle": "Visualizza le informazioni di build per questa applicazione.", @@ -442,7 +446,9 @@ "months": "Mesi", "skipHome": "Salta la home", "skipHomeHelp": "Reindirizza automaticamente al primo volume quando si visita la home. Se non impostato, usa la configurazione del server.", - "useEnvSetting": "Usa impostazione del server" + "useEnvSetting": "Usa impostazione del server", + "showVersionMarks": "Segnalare i file con versioni", + "showVersionMarksHelp": "Un piccolo segno nell’elenco sui file con versioni precedenti, con il loro numero. Un clic apre la cronologia." }, "thumbs": { "subtitle": "Personalizza le miniature di anteprima per immagini e video.", @@ -615,6 +621,53 @@ "sharedSpace": "Le versioni e il cestino condividono lo spazio riservato di ogni volume. Quando scarseggia, se ne vanno prima le versioni vecchie, poi gli elementi del cestino, poi l'ultima versione di ogni file e infine le versioni fissate.", "environmentNote": "I valori predefiniti provengono da VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE e VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Versioni dei file", + "intro": "Tutti i file che hanno versioni precedenti, ovunque si trovino, e lo spazio che occupano. Qui compaiono percorsi di ogni spazio, cartelle personali comprese: per questo la pagina è riservata agli amministratori.", + "search": "Il percorso contiene", + "searchPlaceholder": "Parte di un nome o di una cartella", + "zone": "Spazio", + "anyZone": "Tutti gli spazi", + "state": "Stato", + "anyState": "Tutti", + "sort": "Ordina per", + "sortBytes": "Spazio occupato", + "sortCount": "Numero di versioni", + "sortNewest": "Versione più recente", + "sortPath": "Percorso", + "summary": "{files} file, {versions} versioni, {size}", + "file": "File", + "count": "Versioni", + "size": "Dimensione", + "newest": "Più recente", + "states": { + "live": "Presente", + "trashed": "Nel cestino", + "orphaned": "Scomparso" + }, + "zoneKinds": { + "volume": "Volume {name}", + "personal": "Cartella personale {name}", + "user-volume": "Volume assegnato {name}" + }, + "zoneUnknown": "Spazio sconosciuto", + "pinned": "Fissata", + "unavailable": "Volume non disponibile", + "deleteAll": "Eliminare la cronologia", + "deleteSelected": "Eliminare {count} versione | Eliminare {count} versioni", + "deleteForGood": "Eliminare definitivamente", + "confirmAllTitle": "Eliminare tutte le versioni di {name}?", + "confirmSomeTitle": "Eliminare {count} versione? | Eliminare {count} versioni?", + "confirmMessage": "Quel contenuto viene rimosso dal disco. Il file stesso non viene toccato e nulla di tutto ciò può essere annullato.", + "none": "Nessun file ha versioni precedenti.", + "loadFailed": "Non è stato possibile leggere l’elenco.", + "detailFailed": "Non è stato possibile leggere questa cronologia.", + "deleteFailed": "Non è stato possibile eliminare le versioni.", + "previous": "Precedente", + "next": "Successivo", + "range": "Da {from} a {to} di {total}", + "deleteSelectedNone": "Eliminare le versioni selezionate" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "restored": "{count} link di condivisione ripristinato | {count} link di condivisione ripristinati", "dropped": "{count} link di condivisione eliminato | {count} link di condivisione eliminati" } + }, + "versions": { + "title": "Versioni", + "menu": "Versioni", + "aria": "Versioni del file", + "current": "Versione attuale", + "empty": "Nessuna versione precedente per ora. Ogni salvataggio conserva ciò che sostituisce.", + "disabled": "Le versioni dei file sono disattivate: i salvataggi non ne conservano più di nuove. Quelle qui sotto restano fino alla scadenza.", + "loadFailed": "Impossibile caricare le versioni.", + "notShared": "La cronologia di questo file non è condivisa con te.", + "unavailable": "Il suo contenuto manca dal disco.", + "unknownAuthor": "Autore sconosciuto", + "shareLink": "Qualcuno con il link", + "pinned": "Fissata", + "aside": "Messa da parte", + "asideHelp": "Salvata da un editor aperto prima di un ripristino: conservata qui invece di annullare il ripristino.", + "total": "{count} versione, {size} | {count} versioni, {size}", + "source": { + "editor": "Editor di testo", + "shareEditor": "Editor tramite una condivisione", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Versione ripristinata", + "external": "Modificato fuori dall'app" + }, + "actions": { + "menu": "Azioni sulla versione", + "select": "Seleziona questa versione", + "selectAll": "Seleziona tutto", + "deleteSelected": "Elimina selezionate ({count})", + "deleteAll": "Elimina tutto", + "preview": "Apri in sola lettura", + "download": "Scarica", + "restore": "Ripristina", + "rename": "Assegna un nome…", + "pin": "Fissa", + "unpin": "Sblocca", + "delete": "Elimina" + }, + "confirm": { + "restoreTitle": "Ripristinare questa versione?", + "restoreMessage": "«{name}» torna al contenuto del {date}. Quello attuale viene conservato come versione.", + "deleteTitle": "Eliminare questa versione? | Eliminare {count} versioni?", + "deleteMessage": "Questa versione viene eliminata definitivamente. | Queste {count} versioni vengono eliminate definitivamente.", + "deleteAllTitle": "Eliminare tutte le versioni?", + "deleteAllMessage": "Tutte le versioni precedenti di «{name}» vengono eliminate definitivamente, comprese quelle fissate. Il file resta." + }, + "rename": { + "title": "Assegna un nome a questa versione", + "placeholder": "Per esempio: inviata al cliente", + "help": "Un nome rende facile ritrovare una versione. Fissala per escluderla dalla pulizia automatica." + }, + "results": { + "restored": "Versione ripristinata", + "unchanged": "Il file ha già questo contenuto", + "deleted": "{count} versione eliminata | {count} versioni eliminate", + "renamed": "Nome assegnato alla versione", + "pinned": "Versione fissata: la pulizia automatica la conserva", + "unpinned": "Versione sbloccata" + }, + "errors": { + "action": "L'azione su questa versione non è riuscita" + }, + "mark": "{count} versione precedente | {count} versioni precedenti" } } diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index a3434800..cc806969 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -77,7 +77,9 @@ "theme": "테마", "editorSettings": "에디터 설정", "wrapLines": "자동 줄 바꿈", - "confirmCloseWithoutSaving": "저장되지 않은 변경 사항이 있습니다. 저장하지 않고 종료 하시겠습니까?" + "confirmCloseWithoutSaving": "저장되지 않은 변경 사항이 있습니다. 저장하지 않고 종료 하시겠습니까?", + "trashReadOnly": "휴지통 · 읽기 전용", + "versionReadOnly": "이전 버전, 읽기 전용" }, "status": { "updated": "업데이트 성공", @@ -319,7 +321,8 @@ "dateTaken": "촬영된 날짜: {date}", "camera": "카메라: {makeModel}", "lens": "렌즈: {lens}", - "duration": "길이: {seconds}초" + "duration": "길이: {seconds}초", + "versions": "버전" }, "auth": { "preparing": "탐색기를 준비하는 중입니다…", @@ -392,7 +395,8 @@ "security": "보안", "accessControl": "접근 제어", "adminUsers": "유저 관리", - "trash": "휴지통 및 버전" + "trash": "휴지통 및 버전", + "fileVersions": "파일 버전" }, "about": { "subtitle": "애플리케이션의 빌드 정보를 확인합니다.", @@ -442,7 +446,9 @@ "months": "개월", "skipHome": "홈페이지 건너뛰기", "skipHomeHelp": "접속했을 때, 자동으로 첫 번째 볼륨으로 이동합니다. 설정하지 않을 경우 서버 설정을 따릅니다.", - "useEnvSetting": "서버 설정 사용" + "useEnvSetting": "서버 설정 사용", + "showVersionMarks": "버전이 있는 파일 표시", + "showVersionMarksHelp": "이전 버전이 있는 파일에 개수와 함께 목록에서 작은 표시를 붙입니다. 누르면 기록이 열립니다." }, "thumbs": { "subtitle": "사진, 영상 파일의 미리보기 썸네일 관련 설정을 커스터마이징하세요.", @@ -615,6 +621,53 @@ "sharedSpace": "버전과 휴지통은 각 볼륨의 예약 공간을 함께 사용합니다. 공간이 부족하면 오래된 버전, 휴지통 항목, 각 파일의 최신 버전 순으로 제거되며 고정된 버전은 마지막에 제거됩니다.", "environmentNote": "기본값은 VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE, VERSIONS_SESSION_CHECKPOINT_MINUTES에서 가져옵니다." } + }, + "fileVersions": { + "title": "파일 버전", + "intro": "이전 버전을 가진 모든 파일과 그 버전이 차지하는 용량입니다. 개인 폴더를 포함한 모든 공간의 경로가 나오므로 이 페이지는 관리자 전용입니다.", + "search": "경로에 포함", + "searchPlaceholder": "이름이나 폴더의 일부", + "zone": "공간", + "anyZone": "모든 공간", + "state": "상태", + "anyState": "전체", + "sort": "정렬 기준", + "sortBytes": "사용 용량", + "sortCount": "버전 수", + "sortNewest": "가장 최근 버전", + "sortPath": "경로", + "summary": "파일 {files}개, 버전 {versions}개, {size}", + "file": "파일", + "count": "버전", + "size": "크기", + "newest": "최근", + "states": { + "live": "있음", + "trashed": "휴지통", + "orphaned": "사라짐" + }, + "zoneKinds": { + "volume": "볼륨 {name}", + "personal": "개인 폴더 {name}", + "user-volume": "할당된 볼륨 {name}" + }, + "zoneUnknown": "알 수 없는 공간", + "pinned": "고정됨", + "unavailable": "볼륨을 사용할 수 없음", + "deleteAll": "기록 삭제", + "deleteSelected": "버전 {count}개 삭제", + "deleteForGood": "완전히 삭제", + "confirmAllTitle": "{name}의 모든 버전을 삭제할까요?", + "confirmSomeTitle": "버전 {count}개를 삭제할까요?", + "confirmMessage": "해당 내용은 디스크에서 지워집니다. 파일 자체는 그대로이며, 이 작업은 되돌릴 수 없습니다.", + "none": "이전 버전이 있는 파일이 없습니다.", + "loadFailed": "목록을 읽지 못했습니다.", + "detailFailed": "이 기록을 읽지 못했습니다.", + "deleteFailed": "버전을 삭제하지 못했습니다.", + "previous": "이전", + "next": "다음", + "range": "{total}개 중 {from}–{to}", + "deleteSelectedNone": "선택한 버전 삭제" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "restored": "공유 링크 {count}개를 되살렸습니다", "dropped": "공유 링크 {count}개를 삭제했습니다" } + }, + "versions": { + "title": "버전", + "menu": "버전", + "aria": "파일 버전", + "current": "현재 버전", + "empty": "아직 이전 버전이 없습니다. 저장할 때마다 대체되는 내용이 보관됩니다.", + "disabled": "파일 버전이 꺼져 있습니다. 저장해도 새 버전이 보관되지 않습니다. 아래 버전은 만료될 때까지 남습니다.", + "loadFailed": "버전을 불러오지 못했습니다.", + "notShared": "이 파일의 기록은 공유되지 않았습니다.", + "unavailable": "디스크에서 내용을 찾을 수 없습니다.", + "unknownAuthor": "알 수 없는 작성자", + "shareLink": "링크를 가진 사용자", + "pinned": "고정됨", + "aside": "따로 보관됨", + "asideHelp": "복원 전에 열린 편집기가 저장한 내용입니다. 복원을 되돌리지 않도록 여기에 보관했습니다.", + "total": "버전 {count}개, {size}", + "source": { + "editor": "텍스트 편집기", + "shareEditor": "공유를 통한 편집기", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "복원된 버전", + "external": "앱 외부에서 변경됨" + }, + "actions": { + "menu": "버전 작업", + "select": "이 버전 선택", + "selectAll": "모두 선택", + "deleteSelected": "선택 항목 삭제 ({count})", + "deleteAll": "모두 삭제", + "preview": "읽기 전용으로 열기", + "download": "다운로드", + "restore": "복원", + "rename": "이름 지정…", + "pin": "고정", + "unpin": "고정 해제", + "delete": "삭제" + }, + "confirm": { + "restoreTitle": "이 버전을 복원할까요?", + "restoreMessage": "\"{name}\"이(가) {date}의 내용으로 돌아갑니다. 현재 내용은 버전으로 보관됩니다.", + "deleteTitle": "버전 {count}개를 삭제할까요?", + "deleteMessage": "버전 {count}개가 영구적으로 삭제됩니다.", + "deleteAllTitle": "모든 버전을 삭제할까요?", + "deleteAllMessage": "\"{name}\"의 모든 이전 버전이 고정된 버전까지 영구적으로 삭제됩니다. 파일 자체는 그대로 남습니다." + }, + "rename": { + "title": "이 버전의 이름 지정", + "placeholder": "예: 고객에게 보낸 버전", + "help": "이름을 지정하면 버전을 쉽게 찾을 수 있습니다. 자동 정리에서 제외하려면 고정하세요." + }, + "results": { + "restored": "버전을 복원했습니다", + "unchanged": "파일에 이미 이 내용이 있습니다", + "deleted": "버전 {count}개를 삭제했습니다", + "renamed": "버전 이름을 지정했습니다", + "pinned": "버전을 고정했습니다. 자동 정리에서 보존됩니다", + "unpinned": "버전 고정을 해제했습니다" + }, + "errors": { + "action": "이 버전에 대한 작업이 실패했습니다" + }, + "mark": "이전 버전 {count}개" } } diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 20fa3eb2..d3cb1c0a 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -77,7 +77,9 @@ "theme": "Thema", "editorSettings": "Editor-instellingen", "wrapLines": "Automatische terugloop", - "confirmCloseWithoutSaving": "Er zijn niet-opgeslagen wijzigingen. Sluiten zonder opslaan?" + "confirmCloseWithoutSaving": "Er zijn niet-opgeslagen wijzigingen. Sluiten zonder opslaan?", + "trashReadOnly": "In de prullenbak · alleen-lezen", + "versionReadOnly": "Eerdere versie, alleen-lezen" }, "status": { "updated": "Met succes bijgewerkt", @@ -319,7 +321,8 @@ "dateTaken": "Datum opname: {date}", "camera": "Camera: {makeModel}", "lens": "Lens: {lens}", - "duration": "Duur: {seconds}s" + "duration": "Duur: {seconds}s", + "versions": "Versies" }, "auth": { "preparing": "Verkenner voorbereiden…", @@ -392,7 +395,8 @@ "security": "Beveiliging", "accessControl": "Toegangscontrole", "adminUsers": "Gebruikersbeheer", - "trash": "Prullenbak en versies" + "trash": "Prullenbak en versies", + "fileVersions": "Bestandsversies" }, "about": { "subtitle": "Versieinformatie voor deze applicatie.", @@ -442,7 +446,9 @@ "months": "Maanden", "skipHome": "Startpagina overslaan", "skipHomeHelp": "Automatisch doorsturen naar het eerste volume bij het bezoeken van de startpagina. Als dit niet is ingesteld, wordt de serverconfiguratie gevolgd.", - "useEnvSetting": "Serverconfiguratie gebruiken" + "useEnvSetting": "Serverconfiguratie gebruiken", + "showVersionMarks": "Bestanden met versies markeren", + "showVersionMarksHelp": "Een klein teken in de lijst bij bestanden met eerdere versies, met het aantal. Klik erop om de geschiedenis te openen." }, "thumbs": { "subtitle": "Voorbeeldminiaturen aanpassen voor afbeeldingen en video's.", @@ -615,6 +621,53 @@ "sharedSpace": "Versies en de prullenbak delen de gereserveerde ruimte van elk volume. Wordt die krap, dan gaan eerst oudere versies, daarna items uit de prullenbak, dan de laatste versie van elk bestand en als laatste vastgezette versies.", "environmentNote": "Standaardwaarden komen uit VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE en VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Bestandsversies", + "intro": "Elk bestand met eerdere versies, waar het ook staat, en hoeveel ruimte die innemen. Hier verschijnen paden uit alle ruimtes, persoonlijke mappen inbegrepen — daarom is deze pagina alleen voor beheerders.", + "search": "Pad bevat", + "searchPlaceholder": "Deel van een naam of map", + "zone": "Ruimte", + "anyZone": "Alle ruimtes", + "state": "Status", + "anyState": "Alle", + "sort": "Sorteren op", + "sortBytes": "Gebruikte ruimte", + "sortCount": "Aantal versies", + "sortNewest": "Nieuwste versie", + "sortPath": "Pad", + "summary": "{files} bestanden, {versions} versies, {size}", + "file": "Bestand", + "count": "Versies", + "size": "Grootte", + "newest": "Nieuwste", + "states": { + "live": "Aanwezig", + "trashed": "In de prullenbak", + "orphaned": "Verdwenen" + }, + "zoneKinds": { + "volume": "Volume {name}", + "personal": "Persoonlijke map {name}", + "user-volume": "Toegewezen volume {name}" + }, + "zoneUnknown": "Onbekende ruimte", + "pinned": "Vastgezet", + "unavailable": "Volume niet beschikbaar", + "deleteAll": "Geschiedenis verwijderen", + "deleteSelected": "{count} versie verwijderen | {count} versies verwijderen", + "deleteForGood": "Definitief verwijderen", + "confirmAllTitle": "Alle versies van {name} verwijderen?", + "confirmSomeTitle": "{count} versie verwijderen? | {count} versies verwijderen?", + "confirmMessage": "Die inhoud wordt van de schijf verwijderd. Het bestand zelf blijft ongemoeid, en niets hiervan kan ongedaan worden gemaakt.", + "none": "Geen enkel bestand heeft eerdere versies.", + "loadFailed": "De lijst kon niet worden gelezen.", + "detailFailed": "Deze geschiedenis kon niet worden gelezen.", + "deleteFailed": "De versies konden niet worden verwijderd.", + "previous": "Vorige", + "next": "Volgende", + "range": "{from} tot {to} van {total}", + "deleteSelectedNone": "Aangevinkte versies verwijderen" } }, "share": { @@ -801,5 +854,69 @@ "restored": "{count} deellink hersteld | {count} deellinks hersteld", "dropped": "{count} deellink verwijderd | {count} deellinks verwijderd" } + }, + "versions": { + "title": "Versies", + "menu": "Versies", + "aria": "Bestandsversies", + "current": "Huidige versie", + "empty": "Nog geen eerdere versie. Elke opslag bewaart wat hij vervangt.", + "disabled": "Bestandsversies zijn uitgeschakeld: opslaan bewaart geen nieuwe meer. De versies hieronder blijven tot ze verlopen.", + "loadFailed": "De versies konden niet worden geladen.", + "notShared": "De geschiedenis van dit bestand is niet met u gedeeld.", + "unavailable": "De inhoud ontbreekt op de schijf.", + "unknownAuthor": "Onbekende auteur", + "shareLink": "Iemand met de link", + "pinned": "Vastgezet", + "aside": "Apart gezet", + "asideHelp": "Opgeslagen door een editor die vóór een terugzetting was geopend: hier bewaard in plaats van de terugzetting ongedaan te maken.", + "total": "{count} versie, {size} | {count} versies, {size}", + "source": { + "editor": "Teksteditor", + "shareEditor": "Editor via een deellink", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Teruggezette versie", + "external": "Buiten de app gewijzigd" + }, + "actions": { + "menu": "Versieacties", + "select": "Deze versie selecteren", + "selectAll": "Alles selecteren", + "deleteSelected": "Selectie verwijderen ({count})", + "deleteAll": "Alles verwijderen", + "preview": "Alleen-lezen openen", + "download": "Downloaden", + "restore": "Terugzetten", + "rename": "Naam geven…", + "pin": "Vastzetten", + "unpin": "Losmaken", + "delete": "Verwijderen" + }, + "confirm": { + "restoreTitle": "Deze versie terugzetten?", + "restoreMessage": "\"{name}\" krijgt weer de inhoud van {date}. De huidige inhoud wordt als versie bewaard.", + "deleteTitle": "Deze versie verwijderen? | {count} versies verwijderen?", + "deleteMessage": "Deze versie wordt definitief verwijderd. | Deze {count} versies worden definitief verwijderd.", + "deleteAllTitle": "Alle versies verwijderen?", + "deleteAllMessage": "Elke eerdere versie van \"{name}\" wordt definitief verwijderd, vastgezette inbegrepen. Het bestand zelf blijft." + }, + "rename": { + "title": "Deze versie een naam geven", + "placeholder": "Bijvoorbeeld: naar de klant gestuurd", + "help": "Met een naam is een versie makkelijk terug te vinden. Zet hem vast om hem buiten de automatische opschoning te houden." + }, + "results": { + "restored": "Versie teruggezet", + "unchanged": "Het bestand heeft deze inhoud al", + "deleted": "{count} versie verwijderd | {count} versies verwijderd", + "renamed": "Versie benoemd", + "pinned": "Versie vastgezet: de automatische opschoning bewaart hem", + "unpinned": "Versie losgemaakt" + }, + "errors": { + "action": "De actie op deze versie is mislukt" + }, + "mark": "{count} eerdere versie | {count} eerdere versies" } } diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index f9f7a1b3..c2ea89e8 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "Masz niezapisane zmiany. Zamknąć bez zapisywania?" + "confirmCloseWithoutSaving": "Masz niezapisane zmiany. Zamknąć bez zapisywania?", + "trashReadOnly": "W koszu · tylko do odczytu", + "versionReadOnly": "Wcześniejsza wersja, tylko do odczytu" }, "status": { "updated": "Pomyślnie zaktualizowano", @@ -319,7 +321,8 @@ "dateTaken": "Data wykonania: {date}", "camera": "Aparat: {makeModel}", "lens": "Obiektyw: {lens}", - "duration": "Czas trwania: {seconds}s" + "duration": "Czas trwania: {seconds}s", + "versions": "Wersje" }, "auth": { "preparing": "Trwa przygotowywanie eksploratora…", @@ -392,7 +395,8 @@ "security": "Bezpieczeństwo", "accessControl": "Kontrola dostępu", "adminUsers": "Zarządzanie użytkownikami", - "trash": "Kosz i wersje" + "trash": "Kosz i wersje", + "fileVersions": "Wersje plików" }, "about": { "subtitle": "Wyświetl informacje o kompilacji tej aplikacji.", @@ -442,7 +446,9 @@ "months": "Miesiące", "skipHome": "Pomiń stronę główną", "skipHomeHelp": "Automatycznie przekierowuje do pierwszego wolumenu przy otwieraniu strony głównej. Jeśli nie ustawiono, stosuje konfigurację serwera.", - "useEnvSetting": "Użyj ustawień serwera" + "useEnvSetting": "Użyj ustawień serwera", + "showVersionMarks": "Oznaczaj pliki, które mają wersje", + "showVersionMarksHelp": "Mały znak na liście przy plikach z wcześniejszymi wersjami, wraz z ich liczbą. Kliknięcie otwiera historię." }, "thumbs": { "subtitle": "Dostosuj miniatury podglądu dla obrazów i filmów.", @@ -615,6 +621,53 @@ "sharedSpace": "Wersje i kosz dzielą zarezerwowaną przestrzeń każdego woluminu. Gdy jej brakuje, najpierw znikają starsze wersje, potem elementy kosza, potem ostatnia wersja każdego pliku, a na końcu przypięte wersje.", "environmentNote": "Wartości domyślne pochodzą z VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE i VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Wersje plików", + "intro": "Wszystkie pliki, które mają wcześniejsze wersje, gdziekolwiek się znajdują, i ile te wersje zajmują. Widać tu ścieżki ze wszystkich przestrzeni, także z folderów osobistych — dlatego ta strona jest tylko dla administratorów.", + "search": "Ścieżka zawiera", + "searchPlaceholder": "Część nazwy lub folderu", + "zone": "Przestrzeń", + "anyZone": "Wszystkie przestrzenie", + "state": "Stan", + "anyState": "Wszystkie", + "sort": "Sortuj według", + "sortBytes": "Zajęte miejsce", + "sortCount": "Liczba wersji", + "sortNewest": "Najnowsza wersja", + "sortPath": "Ścieżka", + "summary": "Plików: {files}, wersji: {versions}, {size}", + "file": "Plik", + "count": "Wersje", + "size": "Rozmiar", + "newest": "Najnowsza", + "states": { + "live": "Obecny", + "trashed": "W koszu", + "orphaned": "Zniknął" + }, + "zoneKinds": { + "volume": "Wolumin {name}", + "personal": "Folder osobisty {name}", + "user-volume": "Przypisany wolumin {name}" + }, + "zoneUnknown": "Nieznana przestrzeń", + "pinned": "Przypięta", + "unavailable": "Wolumin niedostępny", + "deleteAll": "Usuń historię", + "deleteSelected": "Usuń {count} wersję | Usuń {count} wersje | Usuń {count} wersji", + "deleteForGood": "Usuń na zawsze", + "confirmAllTitle": "Usunąć wszystkie wersje pliku {name}?", + "confirmSomeTitle": "Usunąć {count} wersję? | Usunąć {count} wersje? | Usunąć {count} wersji?", + "confirmMessage": "Ta zawartość zostaje usunięta z dysku. Sam plik pozostaje nietknięty, a tej operacji nie można cofnąć.", + "none": "Żaden plik nie ma wcześniejszych wersji.", + "loadFailed": "Nie udało się odczytać listy.", + "detailFailed": "Nie udało się odczytać tej historii.", + "deleteFailed": "Nie udało się usunąć wersji.", + "previous": "Poprzednie", + "next": "Następne", + "range": "Od {from} do {to} z {total}", + "deleteSelectedNone": "Usuń zaznaczone wersje" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "restored": "Przywrócone linki udostępniania: {count}", "dropped": "Usunięte linki udostępniania: {count}" } + }, + "versions": { + "title": "Wersje", + "menu": "Wersje", + "aria": "Wersje pliku", + "current": "Bieżąca wersja", + "empty": "Brak wcześniejszych wersji. Każdy zapis zachowuje to, co zastępuje.", + "disabled": "Wersje plików są wyłączone: zapisy nie zachowują już nowych. Poniższe pozostają do wygaśnięcia.", + "loadFailed": "Nie udało się wczytać wersji.", + "notShared": "Historia tego pliku nie jest Ci udostępniona.", + "unavailable": "Brak jej zawartości na dysku.", + "unknownAuthor": "Nieznany autor", + "shareLink": "Ktoś z linkiem", + "pinned": "Przypięta", + "aside": "Odłożona", + "asideHelp": "Zapisana przez edytor otwarty przed przywróceniem: zachowana tutaj zamiast cofać przywrócenie.", + "total": "{count} wersja, {size} | {count} wersje, {size} | {count} wersji, {size}", + "source": { + "editor": "Edytor tekstu", + "shareEditor": "Edytor przez udostępnienie", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Przywrócona wersja", + "external": "Zmieniono poza aplikacją" + }, + "actions": { + "menu": "Działania na wersji", + "select": "Zaznacz tę wersję", + "selectAll": "Zaznacz wszystko", + "deleteSelected": "Usuń zaznaczone ({count})", + "deleteAll": "Usuń wszystkie", + "preview": "Otwórz tylko do odczytu", + "download": "Pobierz", + "restore": "Przywróć", + "rename": "Nazwij…", + "pin": "Przypnij", + "unpin": "Odepnij", + "delete": "Usuń" + }, + "confirm": { + "restoreTitle": "Przywrócić tę wersję?", + "restoreMessage": "„{name}” wraca do zawartości z {date}. Obecna zawartość zostaje zachowana jako wersja.", + "deleteTitle": "Usunąć tę wersję? | Usunąć {count} wersje? | Usunąć {count} wersji?", + "deleteMessage": "Ta wersja zostanie trwale usunięta. | Te {count} wersje zostaną trwale usunięte. | Tych {count} wersji zostanie trwale usuniętych.", + "deleteAllTitle": "Usunąć wszystkie wersje?", + "deleteAllMessage": "Wszystkie wcześniejsze wersje „{name}” zostaną trwale usunięte, łącznie z przypiętymi. Sam plik pozostaje." + }, + "rename": { + "title": "Nazwij tę wersję", + "placeholder": "Na przykład: wysłana do klienta", + "help": "Nazwa ułatwia odnalezienie wersji. Przypnij ją, aby wyłączyć ją z automatycznego czyszczenia." + }, + "results": { + "restored": "Wersja przywrócona", + "unchanged": "Plik ma już tę zawartość", + "deleted": "Usunięto {count} wersję | Usunięto {count} wersje | Usunięto {count} wersji", + "renamed": "Wersja nazwana", + "pinned": "Wersja przypięta: automatyczne czyszczenie ją zachowa", + "unpinned": "Wersja odpięta" + }, + "errors": { + "action": "Działanie na tej wersji nie powiodło się" + }, + "mark": "{count} wcześniejsza wersja | {count} wcześniejsze wersje | {count} wcześniejszych wersji" } } diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index 18ad20c0..0d80726c 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -77,7 +77,9 @@ "theme": "Tema", "editorSettings": "Configurações do editor", "wrapLines": "Quebrar linhas", - "confirmCloseWithoutSaving": "Você tem alterações não salvas. Fechar sem salvar?" + "confirmCloseWithoutSaving": "Você tem alterações não salvas. Fechar sem salvar?", + "trashReadOnly": "Na lixeira · somente leitura", + "versionReadOnly": "Versão anterior, somente leitura" }, "status": { "updated": "Atualizado com sucesso", @@ -319,7 +321,8 @@ "dateTaken": "Data da Foto: {date}", "camera": "Câmera: {makeModel}", "lens": "Lente: {lens}", - "duration": "Duração: {seconds}s" + "duration": "Duração: {seconds}s", + "versions": "Versões" }, "auth": { "preparing": "Preparando seu explorador…", @@ -392,7 +395,8 @@ "security": "Segurança", "accessControl": "Controle de Acesso", "adminUsers": "Gerenciamento de Usuários", - "trash": "Lixeira e versões" + "trash": "Lixeira e versões", + "fileVersions": "Versões de arquivos" }, "about": { "subtitle": "Veja as informações de compilação deste aplicativo.", @@ -442,7 +446,9 @@ "months": "Meses", "skipHome": "Pular página inicial", "skipHomeHelp": "Redirecionar automaticamente para o primeiro volume ao visitar a página inicial. Se não definido, segue a configuração do servidor.", - "useEnvSetting": "Usar configuração do servidor" + "useEnvSetting": "Usar configuração do servidor", + "showVersionMarks": "Marcar arquivos que têm versões", + "showVersionMarksHelp": "Uma pequena marca na lista nos arquivos com versões anteriores, com a quantidade. Clique nela para abrir o histórico." }, "thumbs": { "subtitle": "Personalize as pré-visualizações em miniatura de imagens e vídeos.", @@ -615,6 +621,53 @@ "sharedSpace": "Versões e lixeira dividem o espaço reservado de cada volume. Quando falta espaço, saem primeiro as versões antigas, depois os itens da lixeira, depois a última versão de cada arquivo e, por fim, as versões fixadas.", "environmentNote": "Os padrões vêm de VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE e VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Versões de arquivos", + "intro": "Todos os arquivos com versões anteriores, onde quer que estejam, e o espaço que ocupam. Aparecem aqui caminhos de todos os espaços, inclusive pastas pessoais — por isso esta página é só para administradores.", + "search": "O caminho contém", + "searchPlaceholder": "Parte de um nome ou de uma pasta", + "zone": "Espaço", + "anyZone": "Todos os espaços", + "state": "Situação", + "anyState": "Todos", + "sort": "Ordenar por", + "sortBytes": "Espaço ocupado", + "sortCount": "Número de versões", + "sortNewest": "Versão mais recente", + "sortPath": "Caminho", + "summary": "{files} arquivos, {versions} versões, {size}", + "file": "Arquivo", + "count": "Versões", + "size": "Tamanho", + "newest": "Mais recente", + "states": { + "live": "Presente", + "trashed": "Na lixeira", + "orphaned": "Desaparecido" + }, + "zoneKinds": { + "volume": "Volume {name}", + "personal": "Pasta pessoal {name}", + "user-volume": "Volume atribuído {name}" + }, + "zoneUnknown": "Espaço desconhecido", + "pinned": "Fixada", + "unavailable": "Volume indisponível", + "deleteAll": "Excluir o histórico", + "deleteSelected": "Excluir {count} versão | Excluir {count} versões", + "deleteForGood": "Excluir definitivamente", + "confirmAllTitle": "Excluir todas as versões de {name}?", + "confirmSomeTitle": "Excluir {count} versão? | Excluir {count} versões?", + "confirmMessage": "Esse conteúdo é removido do disco. O arquivo em si não é tocado, e nada disso pode ser desfeito.", + "none": "Nenhum arquivo tem versões anteriores.", + "loadFailed": "Não foi possível ler a lista.", + "detailFailed": "Não foi possível ler este histórico.", + "deleteFailed": "Não foi possível excluir as versões.", + "previous": "Anterior", + "next": "Próximo", + "range": "{from} a {to} de {total}", + "deleteSelectedNone": "Excluir as versões marcadas" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "restored": "{count} link de compartilhamento recuperado | {count} links de compartilhamento recuperados", "dropped": "{count} link de compartilhamento excluído | {count} links de compartilhamento excluídos" } + }, + "versions": { + "title": "Versões", + "menu": "Versões", + "aria": "Versões do arquivo", + "current": "Versão atual", + "empty": "Ainda não há versões anteriores. Cada salvamento guarda o que substitui.", + "disabled": "As versões de arquivos estão desativadas: os salvamentos não guardam mais novas. As abaixo permanecem até expirarem.", + "loadFailed": "Não foi possível carregar as versões.", + "notShared": "O histórico deste arquivo não está compartilhado com você.", + "unavailable": "O conteúdo dela está faltando no disco.", + "unknownAuthor": "Autor desconhecido", + "shareLink": "Alguém com o link", + "pinned": "Fixada", + "aside": "Posta de lado", + "asideHelp": "Salva por um editor aberto antes de uma restauração: guardada aqui em vez de desfazer a restauração.", + "total": "{count} versão, {size} | {count} versões, {size}", + "source": { + "editor": "Editor de texto", + "shareEditor": "Editor por um compartilhamento", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Versão restaurada", + "external": "Alterado fora do aplicativo" + }, + "actions": { + "menu": "Ações da versão", + "select": "Selecionar esta versão", + "selectAll": "Selecionar tudo", + "deleteSelected": "Excluir seleção ({count})", + "deleteAll": "Excluir tudo", + "preview": "Abrir somente leitura", + "download": "Baixar", + "restore": "Restaurar", + "rename": "Nomear…", + "pin": "Fixar", + "unpin": "Desafixar", + "delete": "Excluir" + }, + "confirm": { + "restoreTitle": "Restaurar esta versão?", + "restoreMessage": "\"{name}\" volta ao conteúdo de {date}. O conteúdo atual é guardado como versão.", + "deleteTitle": "Excluir esta versão? | Excluir {count} versões?", + "deleteMessage": "Esta versão é excluída definitivamente. | Estas {count} versões são excluídas definitivamente.", + "deleteAllTitle": "Excluir todas as versões?", + "deleteAllMessage": "Todas as versões anteriores de \"{name}\" são excluídas definitivamente, inclusive as fixadas. O arquivo em si permanece." + }, + "rename": { + "title": "Nomear esta versão", + "placeholder": "Por exemplo: enviada ao cliente", + "help": "Um nome facilita encontrar uma versão. Fixe-a para mantê-la fora da limpeza automática." + }, + "results": { + "restored": "Versão restaurada", + "unchanged": "O arquivo já tem este conteúdo", + "deleted": "{count} versão excluída | {count} versões excluídas", + "renamed": "Versão nomeada", + "pinned": "Versão fixada: a limpeza automática a mantém", + "unpinned": "Versão desafixada" + }, + "errors": { + "action": "A ação nesta versão falhou" + }, + "mark": "{count} versão anterior | {count} versões anteriores" } } diff --git a/frontend/src/i18n/locales/ro.json b/frontend/src/i18n/locales/ro.json index 00ff31fb..1c588ef1 100644 --- a/frontend/src/i18n/locales/ro.json +++ b/frontend/src/i18n/locales/ro.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "Ai modificări nesalvate. Închide fără să salvezi?" + "confirmCloseWithoutSaving": "Ai modificări nesalvate. Închide fără să salvezi?", + "trashReadOnly": "În coșul de gunoi · doar citire", + "versionReadOnly": "Versiune anterioară, doar citire" }, "status": { "updated": "Actualizat cu succes", @@ -319,7 +321,8 @@ "dateTaken": "Data realizării: {date}", "camera": "Cameră: {makeModel}", "lens": "Obiectiv: {lens}", - "duration": "Durată: {seconds}s" + "duration": "Durată: {seconds}s", + "versions": "Versiuni" }, "auth": { "preparing": "Se pregătește explorerul…", @@ -392,7 +395,8 @@ "security": "Securitate", "accessControl": "Control acces", "adminUsers": "Gestionare utilizatori", - "trash": "Coș de gunoi și versiuni" + "trash": "Coș de gunoi și versiuni", + "fileVersions": "Versiuni de fișiere" }, "about": { "subtitle": "Vezi informațiile de build pentru această aplicație.", @@ -442,7 +446,9 @@ "months": "Luni", "skipHome": "Sari peste pagina principală", "skipHomeHelp": "Redirectează automat către primul volum la deschiderea paginii principale. Dacă nu este setat, se folosește configurația serverului.", - "useEnvSetting": "Folosește setarea serverului" + "useEnvSetting": "Folosește setarea serverului", + "showVersionMarks": "Marchează fișierele care au versiuni", + "showVersionMarksHelp": "Un semn discret în listă pe fișierele cu versiuni anterioare, cu numărul lor. Un clic deschide istoricul." }, "thumbs": { "subtitle": "Personalizează miniaturile de previzualizare pentru imagini și video.", @@ -615,6 +621,53 @@ "sharedSpace": "Versiunile și coșul de gunoi împart spațiul rezervat al fiecărui volum. Când nu ajunge, pleacă întâi versiunile vechi, apoi elementele din coș, apoi ultima versiune a fiecărui fișier și, la final, versiunile fixate.", "environmentNote": "Valorile implicite provin din VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE și VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Versiuni de fișiere", + "intro": "Toate fișierele care au versiuni anterioare, oriunde s-ar afla, și spațiul pe care îl ocupă. Aici apar căi din toate spațiile, inclusiv din dosarele personale — de aceea pagina este rezervată administratorilor.", + "search": "Calea conține", + "searchPlaceholder": "O parte dintr-un nume sau dosar", + "zone": "Spațiu", + "anyZone": "Toate spațiile", + "state": "Stare", + "anyState": "Toate", + "sort": "Sortează după", + "sortBytes": "Spațiu ocupat", + "sortCount": "Numărul de versiuni", + "sortNewest": "Cea mai recentă versiune", + "sortPath": "Cale", + "summary": "{files} fișiere, {versions} versiuni, {size}", + "file": "Fișier", + "count": "Versiuni", + "size": "Dimensiune", + "newest": "Cea mai recentă", + "states": { + "live": "Prezent", + "trashed": "La coșul de gunoi", + "orphaned": "Dispărut" + }, + "zoneKinds": { + "volume": "Volumul {name}", + "personal": "Dosar personal {name}", + "user-volume": "Volum atribuit {name}" + }, + "zoneUnknown": "Spațiu necunoscut", + "pinned": "Fixată", + "unavailable": "Volum indisponibil", + "deleteAll": "Șterge istoricul", + "deleteSelected": "Șterge {count} versiune | Șterge {count} versiuni", + "deleteForGood": "Șterge definitiv", + "confirmAllTitle": "Ștergeți toate versiunile lui {name}?", + "confirmSomeTitle": "Ștergeți {count} versiune? | Ștergeți {count} versiuni?", + "confirmMessage": "Acest conținut este șters de pe disc. Fișierul în sine nu este atins, iar nimic din toate acestea nu poate fi anulat.", + "none": "Niciun fișier nu are versiuni anterioare.", + "loadFailed": "Lista nu a putut fi citită.", + "detailFailed": "Acest istoric nu a putut fi citit.", + "deleteFailed": "Versiunile nu au putut fi șterse.", + "previous": "Anterior", + "next": "Următor", + "range": "De la {from} la {to} din {total}", + "deleteSelectedNone": "Șterge versiunile bifate" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "restored": "{count} link de partajare readus | {count} linkuri de partajare readuse", "dropped": "{count} link de partajare șters | {count} linkuri de partajare șterse" } + }, + "versions": { + "title": "Versiuni", + "menu": "Versiuni", + "aria": "Versiunile fișierului", + "current": "Versiunea actuală", + "empty": "Nicio versiune anterioară deocamdată. Fiecare salvare păstrează ce înlocuiește.", + "disabled": "Versiunile fișierelor sunt dezactivate: salvările nu mai păstrează altele noi. Cele de mai jos rămân până expiră.", + "loadFailed": "Versiunile nu au putut fi încărcate.", + "notShared": "Istoricul acestui fișier nu este partajat cu dvs.", + "unavailable": "Conținutul ei lipsește de pe disc.", + "unknownAuthor": "Autor necunoscut", + "shareLink": "Cineva cu linkul", + "pinned": "Fixată", + "aside": "Pusă deoparte", + "asideHelp": "Salvată de un editor deschis înainte de o restaurare: păstrată aici în loc să anuleze restaurarea.", + "total": "{count} versiune, {size} | {count} versiuni, {size}", + "source": { + "editor": "Editor de text", + "shareEditor": "Editor printr-o partajare", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Versiune restaurată", + "external": "Modificat în afara aplicației" + }, + "actions": { + "menu": "Acțiuni pentru versiune", + "select": "Selectați această versiune", + "selectAll": "Selectați tot", + "deleteSelected": "Ștergeți selecția ({count})", + "deleteAll": "Ștergeți tot", + "preview": "Deschideți doar în citire", + "download": "Descărcați", + "restore": "Restaurați", + "rename": "Denumiți…", + "pin": "Fixați", + "unpin": "Anulați fixarea", + "delete": "Ștergeți" + }, + "confirm": { + "restoreTitle": "Restaurați această versiune?", + "restoreMessage": "„{name}” revine la conținutul din {date}. Conținutul actual este păstrat ca versiune.", + "deleteTitle": "Ștergeți această versiune? | Ștergeți {count} versiuni?", + "deleteMessage": "Această versiune este ștearsă definitiv. | Aceste {count} versiuni sunt șterse definitiv.", + "deleteAllTitle": "Ștergeți toate versiunile?", + "deleteAllMessage": "Toate versiunile anterioare ale „{name}” sunt șterse definitiv, inclusiv cele fixate. Fișierul în sine rămâne." + }, + "rename": { + "title": "Denumiți această versiune", + "placeholder": "De exemplu: trimisă clientului", + "help": "Un nume face o versiune ușor de găsit. Fixați-o pentru a o feri de curățarea automată." + }, + "results": { + "restored": "Versiune restaurată", + "unchanged": "Fișierul are deja acest conținut", + "deleted": "{count} versiune ștearsă | {count} versiuni șterse", + "renamed": "Versiune denumită", + "pinned": "Versiune fixată: curățarea automată o păstrează", + "unpinned": "Fixarea versiunii a fost anulată" + }, + "errors": { + "action": "Acțiunea asupra acestei versiuni a eșuat" + }, + "mark": "{count} versiune anterioară | {count} versiuni anterioare" } } diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 2c7df246..a5d67553 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "У вас есть несохраненные изменения. Закрыть без сохранения?" + "confirmCloseWithoutSaving": "У вас есть несохраненные изменения. Закрыть без сохранения?", + "trashReadOnly": "В корзине · только чтение", + "versionReadOnly": "Предыдущая версия, только чтение" }, "status": { "updated": "Успешно обновлено", @@ -319,7 +321,8 @@ "dateTaken": "Дата съемки: {date}", "camera": "Камера: {makeModel}", "lens": "Объектив: {lens}", - "duration": "Длительность: {seconds}с" + "duration": "Длительность: {seconds}с", + "versions": "Версии" }, "auth": { "preparing": "Подготовка проводника…", @@ -392,7 +395,8 @@ "security": "Безопасность", "accessControl": "Контроль доступа", "adminUsers": "Управление пользователями", - "trash": "Корзина и версии" + "trash": "Корзина и версии", + "fileVersions": "Версии файлов" }, "about": { "subtitle": "Информация о сборке приложения.", @@ -442,7 +446,9 @@ "months": "Месяцев", "skipHome": "Пропустить главную", "skipHomeHelp": "Автоматически перенаправляет к первому тому при заходе на главную. Если не указано, используется настройка сервера.", - "useEnvSetting": "Использовать серверную настройку" + "useEnvSetting": "Использовать серверную настройку", + "showVersionMarks": "Отмечать файлы, у которых есть версии", + "showVersionMarksHelp": "Небольшая отметка в списке у файлов с предыдущими версиями и их числом. Нажмите на неё, чтобы открыть историю." }, "thumbs": { "subtitle": "Настройте миниатюры предпросмотра для изображений и видео.", @@ -615,6 +621,53 @@ "sharedSpace": "Версии и корзина делят зарезервированное место каждого тома. Когда его не хватает, сначала удаляются старые версии, затем элементы корзины, затем последняя версия каждого файла и в последнюю очередь закреплённые версии.", "environmentNote": "Значения по умолчанию берутся из VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE и VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Версии файлов", + "intro": "Все файлы с предыдущими версиями, где бы они ни находились, и сколько места те занимают. Здесь видны пути из всех пространств, включая личные папки, — поэтому страница доступна только администраторам.", + "search": "Путь содержит", + "searchPlaceholder": "Часть имени или папки", + "zone": "Пространство", + "anyZone": "Все пространства", + "state": "Состояние", + "anyState": "Все", + "sort": "Сортировать по", + "sortBytes": "Занятое место", + "sortCount": "Число версий", + "sortNewest": "Самая новая версия", + "sortPath": "Путь", + "summary": "Файлов: {files}, версий: {versions}, {size}", + "file": "Файл", + "count": "Версии", + "size": "Размер", + "newest": "Самая новая", + "states": { + "live": "На месте", + "trashed": "В корзине", + "orphaned": "Исчез" + }, + "zoneKinds": { + "volume": "Том {name}", + "personal": "Личная папка {name}", + "user-volume": "Назначенный том {name}" + }, + "zoneUnknown": "Неизвестное пространство", + "pinned": "Закреплена", + "unavailable": "Том недоступен", + "deleteAll": "Удалить историю", + "deleteSelected": "Удалить {count} версию | Удалить {count} версии | Удалить {count} версий", + "deleteForGood": "Удалить навсегда", + "confirmAllTitle": "Удалить все версии файла {name}?", + "confirmSomeTitle": "Удалить {count} версию? | Удалить {count} версии? | Удалить {count} версий?", + "confirmMessage": "Это содержимое удаляется с диска. Сам файл не затрагивается, и отменить это нельзя.", + "none": "Ни у одного файла нет предыдущих версий.", + "loadFailed": "Не удалось прочитать список.", + "detailFailed": "Не удалось прочитать эту историю.", + "deleteFailed": "Не удалось удалить версии.", + "previous": "Назад", + "next": "Вперёд", + "range": "С {from} по {to} из {total}", + "deleteSelectedNone": "Удалить отмеченные версии" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "restored": "Возвращено ссылок общего доступа: {count}", "dropped": "Удалено ссылок общего доступа: {count}" } + }, + "versions": { + "title": "Версии", + "menu": "Версии", + "aria": "Версии файла", + "current": "Текущая версия", + "empty": "Предыдущих версий пока нет. Каждое сохранение оставляет то, что заменяет.", + "disabled": "Версии файлов отключены: сохранения больше не оставляют новых. Версии ниже хранятся до истечения срока.", + "loadFailed": "Не удалось загрузить версии.", + "notShared": "История этого файла вам не открыта.", + "unavailable": "Её содержимое отсутствует на диске.", + "unknownAuthor": "Неизвестный автор", + "shareLink": "Кто-то по ссылке", + "pinned": "Закреплена", + "aside": "Отложена", + "asideHelp": "Сохранена редактором, открытым до восстановления: оставлена здесь, чтобы не отменять восстановление.", + "total": "{count} версия, {size} | {count} версии, {size} | {count} версий, {size}", + "source": { + "editor": "Текстовый редактор", + "shareEditor": "Редактор через общий доступ", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Восстановленная версия", + "external": "Изменено вне приложения" + }, + "actions": { + "menu": "Действия с версией", + "select": "Выбрать эту версию", + "selectAll": "Выбрать все", + "deleteSelected": "Удалить выбранные ({count})", + "deleteAll": "Удалить все", + "preview": "Открыть только для чтения", + "download": "Скачать", + "restore": "Восстановить", + "rename": "Назвать…", + "pin": "Закрепить", + "unpin": "Открепить", + "delete": "Удалить" + }, + "confirm": { + "restoreTitle": "Восстановить эту версию?", + "restoreMessage": "«{name}» вернётся к содержимому от {date}. Текущее содержимое сохранится как версия.", + "deleteTitle": "Удалить эту версию? | Удалить {count} версии? | Удалить {count} версий?", + "deleteMessage": "Эта версия будет удалена безвозвратно. | Эти {count} версии будут удалены безвозвратно. | Эти {count} версий будут удалены безвозвратно.", + "deleteAllTitle": "Удалить все версии?", + "deleteAllMessage": "Все предыдущие версии «{name}» будут удалены безвозвратно, включая закреплённые. Сам файл останется." + }, + "rename": { + "title": "Назвать эту версию", + "placeholder": "Например: отправлена клиенту", + "help": "Название помогает найти версию. Закрепите её, чтобы автоматическая очистка её не трогала." + }, + "results": { + "restored": "Версия восстановлена", + "unchanged": "У файла уже это содержимое", + "deleted": "Удалена {count} версия | Удалено {count} версии | Удалено {count} версий", + "renamed": "Версия названа", + "pinned": "Версия закреплена: автоматическая очистка её сохранит", + "unpinned": "Версия откреплена" + }, + "errors": { + "action": "Не удалось выполнить действие с версией" + }, + "mark": "{count} предыдущая версия | {count} предыдущие версии | {count} предыдущих версий" } } diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index 1ebdc001..c4c4d90c 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "Du har osparade ändringar. Stäng utan att spara?" + "confirmCloseWithoutSaving": "Du har osparade ändringar. Stäng utan att spara?", + "trashReadOnly": "I papperskorgen · skrivskyddad", + "versionReadOnly": "Tidigare version, skrivskyddad" }, "status": { "updated": "Uppdaterades", @@ -319,7 +321,8 @@ "dateTaken": "Datum: {date}", "camera": "Kamera: {makeModel}", "lens": "Lins: {lens}", - "duration": "Speltid: {seconds}s" + "duration": "Speltid: {seconds}s", + "versions": "Versioner" }, "auth": { "preparing": "Förbereder din utforskare…", @@ -392,7 +395,8 @@ "security": "Säkerhet", "accessControl": "Åtkomstkontroll", "adminUsers": "Användarhantering", - "trash": "Papperskorg och versioner" + "trash": "Papperskorg och versioner", + "fileVersions": "Filversioner" }, "about": { "subtitle": "Visa bygginformation för denna applikation.", @@ -442,7 +446,9 @@ "months": "Månader", "skipHome": "Hoppa över startsidan", "skipHomeHelp": "Omdirigerar automatiskt till den första volymen när startsidan öppnas. Om inget anges används serverinställningen.", - "useEnvSetting": "Använd serverinställning" + "useEnvSetting": "Använd serverinställning", + "showVersionMarks": "Märk filer som har versioner", + "showVersionMarksHelp": "Ett litet märke i listan på filer med tidigare versioner, med antalet. Klicka på det för att öppna historiken." }, "thumbs": { "subtitle": "Anpassa förhandsvisningsminiatyrer för bilder och videor.", @@ -615,6 +621,53 @@ "sharedSpace": "Versioner och papperskorgen delar varje volyms reserverade utrymme. När det inte räcker försvinner först äldre versioner, sedan objekt i papperskorgen, sedan varje fils senaste version och sist fästa versioner.", "environmentNote": "Standardvärden kommer från VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE och VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Filversioner", + "intro": "Alla filer som har tidigare versioner, var de än ligger, och hur mycket plats de tar. Här syns sökvägar från alla utrymmen, personliga mappar inräknade — därför är sidan bara för administratörer.", + "search": "Sökvägen innehåller", + "searchPlaceholder": "En del av ett namn eller en mapp", + "zone": "Utrymme", + "anyZone": "Alla utrymmen", + "state": "Status", + "anyState": "Alla", + "sort": "Sortera efter", + "sortBytes": "Upptaget utrymme", + "sortCount": "Antal versioner", + "sortNewest": "Senaste versionen", + "sortPath": "Sökväg", + "summary": "{files} filer, {versions} versioner, {size}", + "file": "Fil", + "count": "Versioner", + "size": "Storlek", + "newest": "Senaste", + "states": { + "live": "Finns kvar", + "trashed": "I papperskorgen", + "orphaned": "Försvunnen" + }, + "zoneKinds": { + "volume": "Volymen {name}", + "personal": "Personlig mapp {name}", + "user-volume": "Tilldelad volym {name}" + }, + "zoneUnknown": "Okänt utrymme", + "pinned": "Fäst", + "unavailable": "Volymen är inte tillgänglig", + "deleteAll": "Ta bort historiken", + "deleteSelected": "Ta bort {count} version | Ta bort {count} versioner", + "deleteForGood": "Ta bort för gott", + "confirmAllTitle": "Ta bort alla versioner av {name}?", + "confirmSomeTitle": "Ta bort {count} version? | Ta bort {count} versioner?", + "confirmMessage": "Det innehållet tas bort från disken. Själva filen rörs inte, och ingenting av detta går att ångra.", + "none": "Ingen fil har tidigare versioner.", + "loadFailed": "Listan kunde inte läsas.", + "detailFailed": "Den här historiken kunde inte läsas.", + "deleteFailed": "Versionerna kunde inte tas bort.", + "previous": "Föregående", + "next": "Nästa", + "range": "{from} till {to} av {total}", + "deleteSelectedNone": "Ta bort de markerade versionerna" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "restored": "{count} delningslänk återställd | {count} delningslänkar återställda", "dropped": "{count} delningslänk borttagen | {count} delningslänkar borttagna" } + }, + "versions": { + "title": "Versioner", + "menu": "Versioner", + "aria": "Filversioner", + "current": "Aktuell version", + "empty": "Inga tidigare versioner än. Varje sparning behåller det den ersätter.", + "disabled": "Filversioner är avstängda: sparningar behåller inga nya. De nedan finns kvar tills de löper ut.", + "loadFailed": "Versionerna kunde inte läsas in.", + "notShared": "Den här filens historik är inte delad med dig.", + "unavailable": "Dess innehåll saknas på disken.", + "unknownAuthor": "Okänd författare", + "shareLink": "Någon med länken", + "pinned": "Fäst", + "aside": "Undanlagd", + "asideHelp": "Sparad av en redigerare som öppnades före en återställning: behålls här i stället för att ångra återställningen.", + "total": "{count} version, {size} | {count} versioner, {size}", + "source": { + "editor": "Textredigerare", + "shareEditor": "Redigerare via en delning", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Återställd version", + "external": "Ändrad utanför appen" + }, + "actions": { + "menu": "Åtgärder för versionen", + "select": "Markera den här versionen", + "selectAll": "Markera alla", + "deleteSelected": "Ta bort markerade ({count})", + "deleteAll": "Ta bort alla", + "preview": "Öppna skrivskyddad", + "download": "Ladda ner", + "restore": "Återställ", + "rename": "Namnge…", + "pin": "Fäst", + "unpin": "Lossa", + "delete": "Ta bort" + }, + "confirm": { + "restoreTitle": "Återställa den här versionen?", + "restoreMessage": "”{name}” får tillbaka sitt innehåll från {date}. Det nuvarande innehållet behålls som en version.", + "deleteTitle": "Ta bort den här versionen? | Ta bort {count} versioner?", + "deleteMessage": "Den här versionen tas bort permanent. | Dessa {count} versioner tas bort permanent.", + "deleteAllTitle": "Ta bort alla versioner?", + "deleteAllMessage": "Alla tidigare versioner av ”{name}” tas bort permanent, även fästa. Själva filen finns kvar." + }, + "rename": { + "title": "Namnge den här versionen", + "placeholder": "Till exempel: skickad till kunden", + "help": "Ett namn gör en version lätt att hitta. Fäst den för att undanta den från den automatiska rensningen." + }, + "results": { + "restored": "Versionen återställd", + "unchanged": "Filen har redan det här innehållet", + "deleted": "{count} version borttagen | {count} versioner borttagna", + "renamed": "Versionen namngiven", + "pinned": "Versionen fäst: den automatiska rensningen behåller den", + "unpinned": "Versionen lossad" + }, + "errors": { + "action": "Åtgärden på den här versionen misslyckades" + }, + "mark": "{count} tidigare version | {count} tidigare versioner" } } diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 54329945..6aedc5d5 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "您有未保存的更改。要在不保存的情况下关闭吗?" + "confirmCloseWithoutSaving": "您有未保存的更改。要在不保存的情况下关闭吗?", + "trashReadOnly": "回收站中 · 只读", + "versionReadOnly": "历史版本,只读" }, "status": { "updated": "更新成功", @@ -319,7 +321,8 @@ "dateTaken": "拍摄日期:{date}", "camera": "相机:{makeModel}", "lens": "镜头:{lens}", - "duration": "时长:{seconds}s" + "duration": "时长:{seconds}s", + "versions": "版本" }, "auth": { "preparing": "正在为您准备 Explorer…", @@ -392,7 +395,8 @@ "security": "安全", "accessControl": "访问控制", "adminUsers": "用户管理", - "trash": "回收站和版本" + "trash": "回收站和版本", + "fileVersions": "文件版本" }, "about": { "subtitle": "查看此应用的构建信息。", @@ -442,7 +446,9 @@ "months": "月", "skipHome": "跳过首页", "skipHomeHelp": "访问首页时自动重定向到第一个卷。未设置时遵循服务器配置。", - "useEnvSetting": "使用服务器设置" + "useEnvSetting": "使用服务器设置", + "showVersionMarks": "标记有版本的文件", + "showVersionMarksHelp": "在列表中为有历史版本的文件加一个小标记,并显示数量。点击即可打开历史记录。" }, "thumbs": { "subtitle": "自定义图片和视频的预览缩略图。", @@ -615,6 +621,53 @@ "sharedSpace": "版本与回收站共用每个卷的预留空间。空间不足时,依次移除旧版本、回收站项目、每个文件的最新版本,最后才是已固定的版本。", "environmentNote": "默认值来自 VERSIONS_ENABLED、VERSIONS_KEEP_ALL_HOURS、VERSIONS_HOURLY_DAYS、VERSIONS_DAILY_DAYS、VERSIONS_MAX_PER_FILE 和 VERSIONS_SESSION_CHECKPOINT_MINUTES。" } + }, + "fileVersions": { + "title": "文件版本", + "intro": "所有存在历史版本的文件,不论位于何处,以及它们占用的空间。这里会列出所有空间的路径,包括个人文件夹——因此本页面仅面向管理员。", + "search": "路径包含", + "searchPlaceholder": "名称或文件夹的一部分", + "zone": "空间", + "anyZone": "全部空间", + "state": "状态", + "anyState": "全部", + "sort": "排序方式", + "sortBytes": "占用空间", + "sortCount": "版本数量", + "sortNewest": "最新版本", + "sortPath": "路径", + "summary": "{files} 个文件,{versions} 个版本,{size}", + "file": "文件", + "count": "版本", + "size": "大小", + "newest": "最新", + "states": { + "live": "仍在", + "trashed": "在回收站", + "orphaned": "已消失" + }, + "zoneKinds": { + "volume": "卷 {name}", + "personal": "个人文件夹 {name}", + "user-volume": "分配的卷 {name}" + }, + "zoneUnknown": "未知空间", + "pinned": "已固定", + "unavailable": "卷不可用", + "deleteAll": "删除历史记录", + "deleteSelected": "删除 {count} 个版本", + "deleteForGood": "永久删除", + "confirmAllTitle": "删除 {name} 的全部版本?", + "confirmSomeTitle": "删除 {count} 个版本?", + "confirmMessage": "该内容将从磁盘上移除。文件本身不受影响,此操作无法撤销。", + "none": "没有文件存在历史版本。", + "loadFailed": "无法读取列表。", + "detailFailed": "无法读取此历史记录。", + "deleteFailed": "无法删除这些版本。", + "previous": "上一页", + "next": "下一页", + "range": "第 {from}–{to} 项,共 {total} 项", + "deleteSelectedNone": "删除勾选的版本" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "restored": "已恢复 {count} 个共享链接", "dropped": "已删除 {count} 个共享链接" } + }, + "versions": { + "title": "版本", + "menu": "版本", + "aria": "文件版本", + "current": "当前版本", + "empty": "暂无历史版本。每次保存都会保留被替换的内容。", + "disabled": "文件版本已关闭:保存时不再保留新版本。下面的版本会保留到过期。", + "loadFailed": "无法加载版本。", + "notShared": "此文件的历史未与你共享。", + "unavailable": "磁盘上找不到其内容。", + "unknownAuthor": "未知作者", + "shareLink": "持有链接的人", + "pinned": "已固定", + "aside": "已搁置", + "asideHelp": "由恢复之前打开的编辑器保存:保留在这里,以免撤销恢复。", + "total": "{count} 个版本,{size}", + "source": { + "editor": "文本编辑器", + "shareEditor": "通过共享的编辑器", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "恢复的版本", + "external": "在应用外修改" + }, + "actions": { + "menu": "版本操作", + "select": "选择此版本", + "selectAll": "全选", + "deleteSelected": "删除所选({count})", + "deleteAll": "全部删除", + "preview": "以只读方式打开", + "download": "下载", + "restore": "恢复", + "rename": "命名…", + "pin": "固定", + "unpin": "取消固定", + "delete": "删除" + }, + "confirm": { + "restoreTitle": "恢复此版本?", + "restoreMessage": "“{name}”将恢复为 {date} 的内容。当前内容会保留为一个版本。", + "deleteTitle": "删除 {count} 个版本?", + "deleteMessage": "{count} 个版本将被永久删除。", + "deleteAllTitle": "删除所有版本?", + "deleteAllMessage": "“{name}”的所有历史版本都将被永久删除,包括已固定的版本。文件本身保留。" + }, + "rename": { + "title": "为此版本命名", + "placeholder": "例如:已发送给客户", + "help": "命名后更容易找到版本。固定它可使其不被自动清理。" + }, + "results": { + "restored": "版本已恢复", + "unchanged": "文件已是此内容", + "deleted": "已删除 {count} 个版本", + "renamed": "版本已命名", + "pinned": "版本已固定:自动清理会保留它", + "unpinned": "已取消固定版本" + }, + "errors": { + "action": "对此版本的操作失败" + }, + "mark": "{count} 个历史版本" } } diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 49fc439e..516a5d46 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "您有未儲存的變更。要在不儲存的情況下關閉嗎?" + "confirmCloseWithoutSaving": "您有未儲存的變更。要在不儲存的情況下關閉嗎?", + "trashReadOnly": "資源回收筒中 · 唯讀", + "versionReadOnly": "先前版本,唯讀" }, "status": { "updated": "更新成功", @@ -319,7 +321,8 @@ "dateTaken": "拍攝日期:{date}", "camera": "相機:{makeModel}", "lens": "鏡頭:{lens}", - "duration": "長度:{seconds}s" + "duration": "長度:{seconds}s", + "versions": "版本" }, "auth": { "preparing": "正在為您準備 Explorer…", @@ -392,7 +395,8 @@ "security": "安全", "accessControl": "權限控制", "adminUsers": "使用者管理", - "trash": "資源回收筒和版本" + "trash": "資源回收筒和版本", + "fileVersions": "檔案版本" }, "about": { "subtitle": "查看此應用的構建資訊。", @@ -442,7 +446,9 @@ "months": "月", "skipHome": "跳過首頁", "skipHomeHelp": "進入首頁時自動導向到第一個儲存卷。未設定時會使用伺服器預設。", - "useEnvSetting": "使用伺服器預設" + "useEnvSetting": "使用伺服器預設", + "showVersionMarks": "標示有版本的檔案", + "showVersionMarksHelp": "在清單中為有舊版本的檔案加上一個小標記,並顯示數量。點一下即可開啟歷程記錄。" }, "thumbs": { "subtitle": "自訂圖片和影片的預覽縮圖。", @@ -615,6 +621,53 @@ "sharedSpace": "版本與資源回收筒共用每個磁碟區的保留空間。空間不足時,依序移除舊版本、資源回收筒項目、每個檔案的最新版本,最後才是已釘選的版本。", "environmentNote": "預設值來自 VERSIONS_ENABLED、VERSIONS_KEEP_ALL_HOURS、VERSIONS_HOURLY_DAYS、VERSIONS_DAILY_DAYS、VERSIONS_MAX_PER_FILE 和 VERSIONS_SESSION_CHECKPOINT_MINUTES。" } + }, + "fileVersions": { + "title": "檔案版本", + "intro": "所有存在舊版本的檔案,不論位於何處,以及它們佔用的空間。這裡會列出所有空間的路徑,包括個人資料夾——因此本頁面僅供管理員使用。", + "search": "路徑包含", + "searchPlaceholder": "名稱或資料夾的一部分", + "zone": "空間", + "anyZone": "全部空間", + "state": "狀態", + "anyState": "全部", + "sort": "排序方式", + "sortBytes": "佔用空間", + "sortCount": "版本數量", + "sortNewest": "最新版本", + "sortPath": "路徑", + "summary": "{files} 個檔案,{versions} 個版本,{size}", + "file": "檔案", + "count": "版本", + "size": "大小", + "newest": "最新", + "states": { + "live": "仍在", + "trashed": "在回收筒", + "orphaned": "已消失" + }, + "zoneKinds": { + "volume": "磁碟區 {name}", + "personal": "個人資料夾 {name}", + "user-volume": "指派的磁碟區 {name}" + }, + "zoneUnknown": "未知空間", + "pinned": "已釘選", + "unavailable": "磁碟區無法使用", + "deleteAll": "刪除歷程記錄", + "deleteSelected": "刪除 {count} 個版本", + "deleteForGood": "永久刪除", + "confirmAllTitle": "刪除 {name} 的全部版本?", + "confirmSomeTitle": "刪除 {count} 個版本?", + "confirmMessage": "該內容會從磁碟上移除。檔案本身不受影響,且此操作無法復原。", + "none": "沒有檔案存在舊版本。", + "loadFailed": "無法讀取清單。", + "detailFailed": "無法讀取此歷程記錄。", + "deleteFailed": "無法刪除這些版本。", + "previous": "上一頁", + "next": "下一頁", + "range": "第 {from}–{to} 項,共 {total} 項", + "deleteSelectedNone": "刪除勾選的版本" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "restored": "已恢復 {count} 個分享連結", "dropped": "已刪除 {count} 個分享連結" } + }, + "versions": { + "title": "版本", + "menu": "版本", + "aria": "檔案版本", + "current": "目前版本", + "empty": "尚無先前版本。每次儲存都會保留被取代的內容。", + "disabled": "檔案版本已關閉:儲存時不再保留新版本。下方的版本會保留到過期。", + "loadFailed": "無法載入版本。", + "notShared": "此檔案的歷程未與你共用。", + "unavailable": "磁碟上找不到其內容。", + "unknownAuthor": "未知作者", + "shareLink": "持有連結的人", + "pinned": "已釘選", + "aside": "已擱置", + "asideHelp": "由還原之前開啟的編輯器儲存:保留在這裡,以免復原還原。", + "total": "{count} 個版本,{size}", + "source": { + "editor": "文字編輯器", + "shareEditor": "透過共用的編輯器", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "還原的版本", + "external": "在應用程式外修改" + }, + "actions": { + "menu": "版本動作", + "select": "選取此版本", + "selectAll": "全選", + "deleteSelected": "刪除所選({count})", + "deleteAll": "全部刪除", + "preview": "以唯讀方式開啟", + "download": "下載", + "restore": "還原", + "rename": "命名…", + "pin": "釘選", + "unpin": "取消釘選", + "delete": "刪除" + }, + "confirm": { + "restoreTitle": "還原此版本?", + "restoreMessage": "「{name}」將回到 {date} 的內容。目前的內容會保留為一個版本。", + "deleteTitle": "刪除 {count} 個版本?", + "deleteMessage": "{count} 個版本將被永久刪除。", + "deleteAllTitle": "刪除所有版本?", + "deleteAllMessage": "「{name}」的所有先前版本都將被永久刪除,包括已釘選的版本。檔案本身保留。" + }, + "rename": { + "title": "為此版本命名", + "placeholder": "例如:已寄給客戶", + "help": "命名後更容易找到版本。釘選它可避免被自動清理。" + }, + "results": { + "restored": "版本已還原", + "unchanged": "檔案已是此內容", + "deleted": "已刪除 {count} 個版本", + "renamed": "版本已命名", + "pinned": "版本已釘選:自動清理會保留它", + "unpinned": "已取消釘選版本" + }, + "errors": { + "action": "對此版本的動作失敗" + }, + "mark": "{count} 個舊版本" } } diff --git a/frontend/src/layouts/BrowserLayout.vue b/frontend/src/layouts/BrowserLayout.vue index 636baecb..fc3a0cc1 100644 --- a/frontend/src/layouts/BrowserLayout.vue +++ b/frontend/src/layouts/BrowserLayout.vue @@ -21,6 +21,7 @@ import { useAuthStore } from '@/stores/auth'; import { useAppSettings } from '@/stores/appSettings'; import { useFeaturesStore } from '@/stores/features'; import InfoPanel from '@/components/InfoPanel.vue'; +import VersionsPanel from '@/components/VersionsPanel.vue'; import { useFileUploader } from '@/composables/fileUploader'; import { useKeyboardShortcuts } from '@/composables/keyboardShortcuts'; import SpotlightSearch from '@/components/SpotlightSearch.vue'; @@ -225,6 +226,7 @@ const handleGuestLogin = () => { + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 6bd3fad7..a88e5fb0 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -14,6 +14,7 @@ import AdminUsers from '@/views/settings/AdminUsers.vue'; import SettingsPassword from '@/views/settings/SettingsPassword.vue'; import SettingsAbout from '@/views/settings/SettingsAbout.vue'; import SettingsTrash from '@/views/settings/SettingsTrash.vue'; +import SettingsFileVersions from '@/views/settings/SettingsFileVersions.vue'; import TrashView from '@/views/TrashView.vue'; import SettingsUserPreferences from '@/views/settings/SettingsUserPreferences.vue'; import AboutView from '@/views/AboutView.vue'; @@ -68,6 +69,11 @@ const router = createRouter({ component: SettingsTrash, meta: { requiresAdmin: true }, }, + { + path: 'file-versions', + component: SettingsFileVersions, + meta: { requiresAdmin: true }, + }, { path: 'admin-overview', component: SettingsComingSoon, @@ -159,6 +165,26 @@ const router = createRouter({ }, ], }, + { + // A file in the trash, shown in the editor to be read: nothing there can + // be saved. Its own path, so no volume name can ever collide with it. + path: '/trash/view', + component: EditorLayout, + meta: { requiresAuth: true }, + children: [ + { path: ':itemId/:entryPath(.*)*', name: 'TrashFileViewer', component: EditorView }, + ], + }, + { + // An earlier version of a file, shown in the editor to be read. The file + // is named by its path, a share path for a share's visitor. + path: '/versions/view', + component: EditorLayout, + meta: { requiresAuth: true, allowGuest: true }, + children: [ + { path: ':versionId/:path(.*)', name: 'VersionFileViewer', component: EditorView }, + ], + }, { path: '/about', component: AboutView, diff --git a/frontend/src/stores/features.js b/frontend/src/stores/features.js index 2156523f..9ded5ea8 100644 --- a/frontend/src/stores/features.js +++ b/frontend/src/stores/features.js @@ -24,6 +24,10 @@ export const useFeaturesStore = defineStore('features', () => { // read it, so the way into the trash could not be shown or hidden. const trashEnabled = ref(false); const trashRetentionDays = ref(null); + // Whether a save keeps what it replaces as a version. The server already + // said so; nothing read it, so a file's history could not be offered or + // hidden. + const versionsEnabled = ref(false); const terminalExtensions = ref([]); const version = ref(''); const gitCommit = ref(''); @@ -88,6 +92,7 @@ export const useFeaturesStore = defineStore('features', () => { personalEnabled.value = Boolean(features?.personal?.enabled); trashEnabled.value = Boolean(features?.trash?.enabled); trashRetentionDays.value = features?.trash?.retentionDays ?? null; + versionsEnabled.value = Boolean(features?.versions?.enabled); // User volumes (per-user volume assignments) userVolumesEnabled.value = Boolean(features?.userVolumes?.enabled); @@ -162,6 +167,7 @@ export const useFeaturesStore = defineStore('features', () => { terminalEnabled, trashEnabled, trashRetentionDays, + versionsEnabled, terminalExtensions, version, gitCommit, diff --git a/frontend/src/stores/versionsPanel.js b/frontend/src/stores/versionsPanel.js new file mode 100644 index 00000000..74b7a069 --- /dev/null +++ b/frontend/src/stores/versionsPanel.js @@ -0,0 +1,48 @@ +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; +import { normalizePath } from '@/api'; + +/** + * Which file the Versions panel shows. + * + * Opened from the file browser with the item that was clicked, or from an + * editor with just the path of the document it has open. `restored` counts the + * restores made from the panel, so an editor showing the same file knows to + * reload what it shows. + */ +export const useVersionsPanelStore = defineStore('versionsPanel', () => { + const isOpen = ref(false); + const item = ref(null); + const restored = ref(0); + + const open = (target) => { + item.value = target || null; + isOpen.value = Boolean(target); + }; + + /** Open on a file known only by its path, as an editor knows it. */ + const openPath = (filePath) => { + const normalized = normalizePath(filePath || ''); + if (!normalized) return; + const segments = normalized.split('/'); + const name = segments.pop(); + open({ name, path: segments.join('/'), kind: 'file' }); + }; + + const close = () => { + isOpen.value = false; + }; + + const markRestored = () => { + restored.value += 1; + }; + + const relativePath = computed(() => { + const it = item.value; + if (!it || !it.name) return ''; + const parent = normalizePath(it.path || ''); + return normalizePath(parent ? `${parent}/${it.name}` : it.name); + }); + + return { isOpen, item, restored, open, openPath, close, markRestored, relativePath }; +}); diff --git a/frontend/src/views/EditorView.vue b/frontend/src/views/EditorView.vue index dafd340c..f57f48a8 100644 --- a/frontend/src/views/EditorView.vue +++ b/frontend/src/views/EditorView.vue @@ -5,10 +5,16 @@ >

- {{ t('editor.editing') }} + {{ + isTrashViewer + ? t('editor.trashReadOnly') + : isVersionViewer + ? t('editor.versionReadOnly') + : t('editor.editing') + }}

- {{ normalizedPath || '—' }} + {{ displayPath || '—' }}

@@ -19,6 +25,7 @@ {{ t('editor.unsavedChanges') }}

diff --git a/frontend/src/views/settings/SettingsView.vue b/frontend/src/views/settings/SettingsView.vue index 6bb5b338..9d660661 100644 --- a/frontend/src/views/settings/SettingsView.vue +++ b/frontend/src/views/settings/SettingsView.vue @@ -12,6 +12,7 @@ import { PhotoIcon, KeyIcon, TrashIcon, + ClockIcon, UsersIcon, UserCircleIcon, } from '@heroicons/vue/24/outline'; @@ -82,6 +83,12 @@ const adminCategories = [ name: 'Trash', icon: TrashIcon, }, + { + key: 'file-versions', + i18nKey: 'fileVersions', + name: 'File versions', + icon: ClockIcon, + }, { key: 'access-control', i18nKey: 'accessControl', From 81a45814b633ca0df68a0a7326580e96bf34a295 Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 10:57:21 +0200 Subject: [PATCH 2/2] Let a preference the screen offers actually be saved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Which keys are preferences was decided twice: once in the settings service, which sanitises the value, and once in the settings route, which decides whether the key is written at all. They had drifted. The preference added here — the mark on files that have versions — reached the service's list and not the route's, so the toggle moved on screen, the save answered success, and nothing was stored. It would have read as "my setting does not stick", with two lists to find before anyone could say why. One list now, named and exported by the service; the route asks it. A test walks every key the service calls a preference and checks the route keeps it, so the two cannot drift again. --- backend/src/routes/settings.js | 14 ++-- backend/src/services/settingsService.js | 30 +++++-- .../tests/routes/settings-preferences.test.js | 83 +++++++++++++++++++ 3 files changed, 110 insertions(+), 17 deletions(-) create mode 100644 backend/tests/routes/settings-preferences.test.js diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index 5bcc0919..e4ba073f 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -3,6 +3,7 @@ const { getPublicSettings, getSettingsForUser, setUserSetting, + USER_SETTING_KEYS, setSystemSetting, getSettings, } = require('../services/settingsService'); @@ -155,15 +156,10 @@ router.patch( if (payload.user && typeof payload.user === 'object' && user && user.id) { const userUpdates = {}; for (const [key, value] of Object.entries(payload.user)) { - if ( - key === 'showHiddenFiles' || - key === 'showThumbnails' || - key === 'showSidebarFavorites' || - key === 'showSidebarShares' || - key === 'showSidebarTools' || - key === 'defaultShareExpiration' || - key === 'skipHome' - ) { + // Which keys are preferences is the settings service's to say: this + // route used to keep a second list of its own, and a preference added + // to one and not the other was silently dropped here. + if (USER_SETTING_KEYS.has(key)) { userUpdates[key] = await setUserSetting(user.id, key, value); } } diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 2a9204e6..0bb483bf 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -292,6 +292,26 @@ const getSettingsForUser = async (user) => { return result; }; +/** + * The preferences an account may set, in one place. + * + * There used to be two lists: this one, which decides how a value is + * sanitised, and another inside the settings route, which decides whether the + * key is written at all. Adding a preference to one and not the other produced + * a toggle that moved on screen, answered success, and stored nothing — so the + * two are the same list now, and the route asks here. + */ +const USER_BOOLEAN_SETTINGS = new Set([ + 'showHiddenFiles', + 'showThumbnails', + 'showVersionMarks', + 'showSidebarFavorites', + 'showSidebarShares', + 'showSidebarTools', +]); + +const USER_SETTING_KEYS = new Set([...USER_BOOLEAN_SETTINGS, 'defaultShareExpiration', 'skipHome']); + /** * Set a user setting */ @@ -305,14 +325,7 @@ const setUserSetting = async (userId, key, value) => { // Validate and sanitize value based on key let sanitizedValue = value; - if ( - key === 'showHiddenFiles' || - key === 'showThumbnails' || - key === 'showVersionMarks' || - key === 'showSidebarFavorites' || - key === 'showSidebarShares' || - key === 'showSidebarTools' - ) { + if (USER_BOOLEAN_SETTINGS.has(key)) { sanitizedValue = Boolean(value); } else if (key === 'defaultShareExpiration') { // Validate expiration object: { value: number, unit: 'days'|'weeks'|'months' } or null @@ -471,6 +484,7 @@ const updateSettings = async (updater) => { }; module.exports = { + USER_SETTING_KEYS, getPublicSettings, sanitizeTrash, sanitizeVersions, diff --git a/backend/tests/routes/settings-preferences.test.js b/backend/tests/routes/settings-preferences.test.js new file mode 100644 index 00000000..f8ab63b8 --- /dev/null +++ b/backend/tests/routes/settings-preferences.test.js @@ -0,0 +1,83 @@ +import express from 'express'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Saving a preference, through the route the screen uses. + * + * Which keys are preferences was decided twice: once in the settings service, + * which sanitises the value, and once in this route, which decides whether the + * key is written at all. A preference added to one and not the other produced a + * toggle that moved on screen, answered success, and stored nothing. + */ + +let env; +let app; +let alice; + +const load = (relative) => require(modulePath(relative)); + +beforeEach(async () => { + env = await setupTestEnv({ tag: 'settings-preferences-' }); + alice = await load('src/services/users').createLocalUser({ + email: 'alice@example.com', + username: 'alice', + displayName: 'Alice', + password: 'secret123', + roles: ['user'], + }); + + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = alice; + next(); + }); + app.use('/api', load('src/routes/settings')); + app.use(load('src/middleware/errorHandler').errorHandler); +}); + +afterEach(async () => { + load('src/services/trash/maintenance').stop(); + await env.cleanup(); +}); + +const save = (user) => request(app).patch('/api/settings').send({ user }); + +const stored = async () => load('src/services/settingsService').getUserSettings(alice.id); + +describe('a preference the screen offers', () => { + it.each(['showHiddenFiles', 'showThumbnails', 'showVersionMarks', 'showSidebarFavorites'])( + 'is written when %s is saved', + async (key) => { + const response = await save({ [key]: true }); + + expect(response.status).toBe(200); + expect(response.body.user[key]).toBe(true); + expect((await stored())[key]).toBe(true); + } + ); + + /** + * Every key the service knows how to sanitise is a key this route accepts: + * one list, so neither can gain a preference the other drops. + */ + it('accepts exactly what the settings service calls a preference', async () => { + const { USER_SETTING_KEYS } = load('src/services/settingsService'); + + for (const key of USER_SETTING_KEYS) { + const value = key === 'defaultShareExpiration' || key === 'skipHome' ? null : true; + const response = await save({ [key]: value }); + expect(response.body.user, `${key} was dropped`).toHaveProperty(key); + } + }); + + it('ignores a key that is not a preference', async () => { + const response = await save({ isAdmin: true }); + + expect(response.body.user ?? {}).toEqual({}); + expect((await stored()).isAdmin).toBeUndefined(); + }); +});