From 7792c4a32c81c89bb34412c04b0c63de2efd1e3e Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 10:03:53 +0200 Subject: [PATCH 1/4] 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/4] 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(); + }); +}); From 9f3b89ba255ef25f14aa9743186773de9699b876 Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 10:20:13 +0200 Subject: [PATCH 3/4] Keep an open document's identity, and know where it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two people editing the same document only see each other when the Document Server was given the same key: a different key is a different document to it, and it opens a second, independent session on the same file. Whoever saves last then overwrites the other, with nothing to warn either of them. The key was recomputed on every open from the file's own modification time. So it changed under the people already editing — every save of theirs split the session — and it did not change once they had all left, which is the one moment it must, because the Document Server caches the prepared document under that key and serves the stale copy on the next open. The key is now kept: the same for as long as anyone has the document open, including across their saves, and dropped when the Document Server reports it has let go. Restoring a version drops it too, which is the piece the history batch could not carry: an editor reopened after a restore was getting the copy the restore had just replaced. The other half is where the document is. The Document Server is handed a token when the editor opens and returns it unchanged with every save, so the token says where the document *was*. Renaming from the title bar makes that stale at once. There are now editing sessions, stored rather than held in memory — a restart mid-edit would otherwise forget the rename, and the next save would recreate the old name beside the new one, silently. Renaming from inside the editor moves the file through the ordinary rename, with the ordinary permission checks, and then moves the session, the presence record and the key with it. Presence starts when the client says the document is really open, not when the configuration was asked for: asking for a configuration says nothing about whether the document opened, and a file the editor then refused was shown to everybody as being edited until it expired. Four things were wrong in this path and are fixed here: - **A read-only folder handed out an editing session.** The decision looked at the requested mode and at whether the document came through a read-only share, and never at the location's own rights. The backend token now carries that decision, and the callback refuses a save made with a token that says read-only. - **The backend token was untyped and never expired.** It is signed with the same secret as the Document Server's own tokens, so one of those would have been accepted as ours. It now carries a type claim and a twelve-hour life. - **Every unknown extension was announced as a text document.** The Document Server validates documentType against the extension and refuses the config outright, answering "the file content does not match the file extension" — true, unhelpful, and several steps from the setting that caused it. .odg was one of them: the Document Server counts a drawing as a presentation. The mapping is now the one it ships, and an extension it has no editor for is refused here, naming the setting. - **Nothing ever expired.** Only a terminal callback released a key, so a browser closed on the editor left the row for good — one for every document ever opened. There is now an hourly sweep, and it takes with it the two cleanups that were already in the tree with no caller at all: `cleanupExpiredShares` and `cleanupExpiredSessions`, so an expired share no longer sits on disk indefinitely. The rename itself moved out of its route into a service, because two callers now need exactly those checks in exactly that order. One thing changed on the way: `ensureValidName` throws a plain Error, which the error handler could only read as a server fault, so a name with a separator in it was answered 500 and appeared in the logs as an unhandled failure. It is a bad request. Gates: lint and formatting on the changed files, `npm run build`, every backend module loads, and the whole backend suite — 1,972 pass, against the two that fail on `main` before any of this. Twenty-three new tests; every claim above fails when the change behind it is put back, mutated one at a time: the key back to its inline computation, the type claim removed, the write check removed from both ends, the save target back to the token, and the editor table back to its catch-all. Checked in the built image with a volume: the schema migrates, a document is handed a key and a session, the heartbeat reports it open, opening it again while it is open gives the same key, an unknown extension is refused by name, renaming from the session moves the file and the folder lists the new name, and ending the session is accepted. Second half — editing together as the file list shows it, Save as, mentions, force-save, the history inside the editor, and opening a document in a tab of its own — follows under the same number. --- backend/src/routes/files/rename.js | 98 +---- backend/src/routes/onlyoffice.js | 355 +++++++++++++-- backend/src/server.js | 35 ++ backend/src/services/db.js | 54 +++ .../src/services/onlyofficeActivityService.js | 232 ++++++++++ .../services/onlyofficeDocumentKeyService.js | 165 +++++++ .../onlyofficeEditorSessionService.js | 108 +++++ backend/src/services/renameService.js | 111 +++++ backend/src/services/versions/index.js | 16 +- backend/src/utils/onlyofficeDocumentTypes.js | 116 +++++ .../tests/routes/onlyoffice-session.test.js | 413 ++++++++++++++++++ frontend/src/api/onlyoffice.api.js | 74 +++- frontend/src/i18n/locales/de.json | 5 + frontend/src/i18n/locales/en.json | 5 + frontend/src/i18n/locales/es.json | 5 + frontend/src/i18n/locales/fr.json | 5 + frontend/src/i18n/locales/hi.json | 5 + frontend/src/i18n/locales/it.json | 5 + frontend/src/i18n/locales/ko.json | 5 + frontend/src/i18n/locales/nl.json | 5 + frontend/src/i18n/locales/pl.json | 5 + frontend/src/i18n/locales/pt-BR.json | 5 + frontend/src/i18n/locales/ro.json | 5 + frontend/src/i18n/locales/ru.json | 5 + frontend/src/i18n/locales/sv.json | 5 + frontend/src/i18n/locales/zh-CN.json | 5 + frontend/src/i18n/locales/zh-TW.json | 5 + .../plugins/onlyoffice/OnlyOfficePreview.vue | 130 +++++- 28 files changed, 1846 insertions(+), 136 deletions(-) create mode 100644 backend/src/services/onlyofficeActivityService.js create mode 100644 backend/src/services/onlyofficeDocumentKeyService.js create mode 100644 backend/src/services/onlyofficeEditorSessionService.js create mode 100644 backend/src/services/renameService.js create mode 100644 backend/src/utils/onlyofficeDocumentTypes.js create mode 100644 backend/tests/routes/onlyoffice-session.test.js diff --git a/backend/src/routes/files/rename.js b/backend/src/routes/files/rename.js index 19a83acb..a576e128 100644 --- a/backend/src/routes/files/rename.js +++ b/backend/src/routes/files/rename.js @@ -1,23 +1,6 @@ -const path = require('path'); -const fs = require('fs/promises'); -const { - normalizeRelativePath, - combineRelativePath, - ensureValidName, -} = require('../../utils/pathUtils'); -const { pathExists } = require('../../utils/fsUtils'); -const { - ACTIONS, - authorizeAndResolve, - authorizePath, -} = require('../../services/authorizationService'); +const { normalizeRelativePath } = require('../../utils/pathUtils'); +const { renameEntry } = require('../../services/renameService'); const asyncHandler = require('../../utils/asyncHandler'); -const { - ValidationError, - ForbiddenError, - NotFoundError, - ConflictError, -} = require('../../errors/AppError'); const { buildItemMetadata } = require('./utils'); const router = require('express').Router(); @@ -25,77 +8,16 @@ const router = require('express').Router(); router.post( '/files/rename', asyncHandler(async (req, res) => { - const parentPath = req.body?.path ?? ''; - const originalName = req.body?.name; - const newNameRaw = req.body?.newName; + const parentRelative = normalizeRelativePath(req.body?.path ?? ''); - if (typeof originalName !== 'string' || !originalName) { - throw new ValidationError('Original name is required.'); - } + const renamed = await renameEntry({ + context: { user: req.user, guestSession: req.guestSession }, + parentRelative, + currentName: req.body?.name, + newName: req.body?.newName, + }); - const parentRelative = normalizeRelativePath(parentPath); - const context = { user: req.user, guestSession: req.guestSession }; - - const { - allowed: parentAllowed, - accessInfo: parentAccess, - resolved: parentResolved, - } = await authorizeAndResolve(context, parentRelative, ACTIONS.write); - if (!parentAllowed || !parentResolved) { - throw new ForbiddenError(parentAccess?.denialReason || 'Destination path is read-only.'); - } - - const { absolutePath: parentAbsolute } = parentResolved; - - const currentRelative = combineRelativePath(parentRelative, originalName); - const { - allowed: currentAllowed, - accessInfo: currentAccess, - resolved: currentResolved, - } = await authorizeAndResolve(context, currentRelative, ACTIONS.write); - if (!currentAllowed || !currentResolved) { - throw new ForbiddenError(currentAccess?.denialReason || 'Cannot rename items in this path.'); - } - - const { absolutePath: currentAbsolute } = currentResolved; - - if (!(await pathExists(currentAbsolute))) { - throw new NotFoundError('Item not found.'); - } - - const validatedNewName = typeof newNameRaw === 'string' ? ensureValidName(newNameRaw) : null; - - if (!validatedNewName) { - throw new ValidationError('A new name is required.'); - } - - if (validatedNewName === originalName) { - const item = await buildItemMetadata(currentAbsolute, parentRelative, originalName); - res.json({ success: true, item }); - return; - } - - const targetRelative = combineRelativePath(parentRelative, validatedNewName); - const { allowed: targetAllowed, accessInfo: targetAccess } = await authorizePath( - context, - targetRelative, - ACTIONS.write - ); - if (!targetAllowed) { - throw new ForbiddenError(targetAccess?.denialReason || 'Destination path is not accessible.'); - } - const targetAbsolute = path.join(parentAbsolute, validatedNewName); - - if (await pathExists(targetAbsolute)) { - throw new ConflictError(`The name "${validatedNewName}" is already taken.`); - } - - await fs.rename(currentAbsolute, targetAbsolute); - // The history follows the file, or everything in the folder, to its new name. - // eslint-disable-next-line global-require - await require('../../services/versions/lifecycle').onMoved(currentAbsolute, targetAbsolute); - - const item = await buildItemMetadata(targetAbsolute, parentRelative, validatedNewName); + const item = await buildItemMetadata(renamed.absolutePath, parentRelative, renamed.name); res.json({ success: true, item }); }) ); diff --git a/backend/src/routes/onlyoffice.js b/backend/src/routes/onlyoffice.js index 8c88b9dc..61623b2a 100644 --- a/backend/src/routes/onlyoffice.js +++ b/backend/src/routes/onlyoffice.js @@ -11,28 +11,29 @@ const { onlyoffice, public: publicConfig, mimeTypes } = require('../config/index const { normalizeRelativePath } = require('../utils/pathUtils'); const { ensureDir } = require('../utils/fsUtils'); const { resolvePathWithAccess } = require('../services/accessManager'); +const { renameEntry } = require('../services/renameService'); const versions = require('../services/versions/operations'); +const onlyofficeActivity = require('../services/onlyofficeActivityService'); +const documentKeys = require('../services/onlyofficeDocumentKeyService'); +const editorSessions = require('../services/onlyofficeEditorSessionService'); +const { getDocumentType } = require('../utils/onlyofficeDocumentTypes'); const logger = require('../utils/logger'); const asyncHandler = require('../utils/asyncHandler'); const { ValidationError, UnauthorizedError, ForbiddenError } = require('../errors/AppError'); const router = express.Router(); -// Helpers -const SUPPORTED_TEXT = new Set(['docx', 'doc', 'odt', 'rtf', 'txt']); -const SUPPORTED_SHEET = new Set(['xlsx', 'xls', 'ods', 'csv']); -const SUPPORTED_PRESENTATION = new Set(['pptx', 'ppt', 'odp']); +// The backend token is signed with the same secret as the Document Server +// tokens, so it carries a type claim to keep the two apart, and a lifetime long +// enough for an editing session but not indefinite. It used to have neither: a +// token from the Document Server would have been accepted as one of ours, and +// one of ours never expired. +const BACKEND_TOKEN_TYPE = 'nextexplorer-backend'; +const BACKEND_TOKEN_TTL_SECONDS = 12 * 60 * 60; +// Helpers const toExt = (filename = '') => String(filename).split('.').pop().toLowerCase(); -const getDocumentType = (ext) => { - // ONLYOFFICE expects: 'word' | 'cell' | 'slide' - if (SUPPORTED_TEXT.has(ext)) return 'word'; - if (SUPPORTED_SHEET.has(ext)) return 'cell'; - if (SUPPORTED_PRESENTATION.has(ext)) return 'slide'; - return 'word'; -}; - const resolveMime = (ext) => mimeTypes[ext] || 'application/octet-stream'; /** @@ -118,6 +119,128 @@ const authorFromCallback = (body, backendCtx) => { return { id, label: user?.name ? String(user.name) : null }; }; +/** + * Read a backend token from the query string. + * + * Returns null unless the token is valid, is a backend token — not a Document + * Server one signed with the same secret — and carries an absolute path. + */ +const readBackendToken = (req) => { + const raw = typeof req.query?.backend === 'string' ? req.query.backend : null; + if (!raw || !onlyoffice.secret) return null; + try { + const payload = jwt.verify(raw, onlyoffice.secret, { algorithms: ['HS256'] }); + if (!payload || typeof payload !== 'object') return null; + if (payload.typ !== BACKEND_TOKEN_TYPE) return null; + if (typeof payload.absolutePath !== 'string' || !payload.absolutePath) return null; + return payload; + } catch (error) { + logger.warn({ err: error }, 'ONLYOFFICE backend token verification failed'); + return null; + } +}; + +/** + * A backend token lives twelve hours; the share it was issued for may not. + * Confirm the share still exists before honouring the token's write claim. + */ +const assertShareStillValid = async (backendCtx) => { + if (!backendCtx?.shareToken) return; + // Required here rather than at the top: the shares service reaches back into + // routes for its own helpers. + // eslint-disable-next-line global-require + const { getShareByToken, isShareExpired } = require('../services/sharesService'); + const share = await getShareByToken(backendCtx.shareToken); + if (!share || isShareExpired(share)) { + throw new ForbiddenError('The share for this editing session is no longer available.'); + } +}; + +const getSessionOwner = (req) => ({ + userId: req.user?.id ? String(req.user.id) : null, + guestSessionId: req.guestSession?.id ? String(req.guestSession.id) : null, +}); + +const matchesSessionOwner = (session, req) => { + const owner = getSessionOwner(req); + return owner.userId === session.userId && owner.guestSessionId === session.guestSessionId; +}; + +const describeSessionUser = (req) => { + const owner = getSessionOwner(req); + return { + id: owner.userId || (owner.guestSessionId ? `guest_${owner.guestSessionId}` : null), + name: req.user?.displayName || req.user?.username || (owner.guestSessionId ? 'Guest' : 'User'), + }; +}; + +/** + * The key to hand this editor. + * + * Whether anyone currently has the document open is what separates "the file + * changed because we are editing it" from "the file changed while nobody was + * looking": the first must keep the key, the second must not. + */ +const resolveKeyForOpen = async ({ absolutePath, relativePath, stat, documentType }) => { + const presence = onlyofficeActivity.get(absolutePath); + return documentKeys.resolveDocumentKey({ + relativePath, + stat, + documentType, + inUse: Boolean(presence?.active), + }); +}; + +/** + * Presence is deliberately not recorded here. + * + * This runs when the editor asks for its configuration, which says nothing + * about whether the document will open. A file the editor then refused — a + * drawing announced with the wrong editor, say — would still be displayed as + * being edited, by everyone, until the session expired. The client reports + * presence once ONLYOFFICE says the document is ready, through the heartbeat. + */ +const createEditorSession = async (req, relativePath, key, absolutePath) => { + void editorSessions.purgeExpired(); + const sessionId = crypto.randomUUID(); + await editorSessions.create({ + sessionId, + key, + relativePath, + // Where the document is *now*. The backend token carries the path as it was + // when the editor opened, and renaming makes that copy wrong; a save + // arriving afterwards would recreate the old name beside the new one. + absolutePath, + ...getSessionOwner(req), + }); + return sessionId; +}; + +const getEditorSession = async (req, sessionId, relativePath) => { + const session = await editorSessions.get(sessionId); + if (!session || session.relativePath !== relativePath || !matchesSessionOwner(session, req)) { + throw new ForbiddenError( + 'The ONLYOFFICE editing session is no longer valid. Reopen the document.' + ); + } + await editorSessions.touch(sessionId); + return session; +}; + +/** + * Where a save should be written for this token. + * + * The token is minted once and handed to the Document Server, which returns it + * unchanged however long the editing session lasts. The session is what follows + * the document if it is renamed meanwhile — and it is stored, so a restart does + * not forget the rename and put the save back under the old name. The token + * remains the fallback for a session that has genuinely expired. + */ +const resolveSaveTarget = async (backendCtx) => { + const session = backendCtx?.sessionId ? await editorSessions.get(backendCtx.sessionId) : null; + return session?.absolutePath || backendCtx.absolutePath; +}; + const getDsJwtFromReq = (req) => { const auth = (req.headers['authorization'] || req.headers['authorizationjwt'] || '').toString(); if (auth.toLowerCase().startsWith('bearer ')) { @@ -165,9 +288,26 @@ router.post( // Check if this is a readonly share const isReadonlyShare = resolved.shareInfo && resolved.shareInfo.accessMode === 'readonly'; + // Disable editing for readonly shares, readonly locations, or view mode. + // Computed before the backend token is signed: the token carries this + // decision, so a viewer never receives one that allows writing. It used to + // ignore the location's own rights, so somebody who could only read a + // folder was handed an editing session on the documents in it. + const canEdit = mode !== 'view' && !isReadonlyShare && accessInfo.canWrite === true; + const filename = path.basename(abs); const ext = toExt(filename); const documentType = getDocumentType(ext); + if (!documentType) { + // Refused here rather than left for the Document Server to open with the + // wrong editor: everything used to fall back to 'word', so a drawing was + // answered "the file content does not match the file extension" — true, + // unhelpful, and several steps from the setting that caused it. + throw new ValidationError( + `ONLYOFFICE has no editor for .${ext} files. Remove it from ONLYOFFICE_FILE_EXTENSIONS, ` + + 'or open it with Collabora instead.' + ); + } const fileUrl = new URL(`/api/onlyoffice/file`, publicConfig.url); fileUrl.searchParams.set('path', relativePath); @@ -175,34 +315,46 @@ router.post( const callbackUrl = new URL(`/api/onlyoffice/callback`, publicConfig.url); callbackUrl.searchParams.set('path', relativePath); + // Shared with anyone already in this document, so they edit together rather + // than in two sessions that overwrite each other. It used to be recomputed + // from the file's own state on every open, which changed it under the + // people already editing. + const key = await resolveKeyForOpen({ + absolutePath: abs, + relativePath, + stat, + documentType, + }); + + // Only an editing session gets one: it is what a save is written through. + const editorSessionId = canEdit ? await createEditorSession(req, relativePath, key, abs) : null; + // Backend context for storage requests (signed separately and passed via query) let backendToken = null; if (onlyoffice.secret) { const backendPayload = { + typ: BACKEND_TOKEN_TYPE, absolutePath: abs, logicalPath: resolved.relativePath, space: resolved.space, + // The callback trusts this flag instead of re-resolving permissions, so + // it must say what this session is actually allowed to do. + canWrite: canEdit, + // Lets a save find the document again if it was renamed while open; the + // path above is only what it was called when the editor started. + sessionId: editorSessionId, userId: req.user && req.user.id ? String(req.user.id) : null, guestSessionId: req.guestSession?.id || null, shareToken: resolved.shareInfo?.shareToken || null, }; backendToken = jwt.sign(backendPayload, onlyoffice.secret, { algorithm: 'HS256', + expiresIn: BACKEND_TOKEN_TTL_SECONDS, }); fileUrl.searchParams.set('backend', backendToken); callbackUrl.searchParams.set('backend', backendToken); } - // Unique key should change when file changes to bust DS cache - const key = crypto - .createHash('sha256') - .update(relativePath) - .update(String(stat.mtimeMs)) - .digest('hex'); - - // Disable editing for readonly shares or when mode is view - const canEdit = mode !== 'view' && !isReadonlyShare; - const config = { documentType, // text | spreadsheet | presentation type: 'desktop', @@ -257,10 +409,116 @@ router.post( res.json({ documentServerUrl: onlyoffice.serverUrl, config, + editorSessionId, }); }) ); +/** + * The client says the document is really open, and goes on saying so. + * + * Presence starts here rather than when the configuration is handed out: that + * says nothing about whether the document opened, and a file the editor then + * refused was still shown to everybody as being edited until it expired. + */ +router.post( + '/onlyoffice/session-heartbeat', + asyncHandler(async (req, res) => { + const relativePath = normalizeRelativePath(req.body?.path || ''); + const sessionId = req.body?.sessionId || ''; + if (!relativePath || typeof sessionId !== 'string' || !sessionId) { + throw new ValidationError('A valid ONLYOFFICE editing session is required.'); + } + const context = { user: req.user, guestSession: req.guestSession }; + const { accessInfo, resolved } = await resolvePathWithAccess(context, relativePath); + if (!accessInfo?.canAccess || !accessInfo.canRead) throw new ForbiddenError('Access denied.'); + await getEditorSession(req, sessionId, relativePath); + + const active = onlyofficeActivity.touch({ + absolutePath: resolved.absolutePath, + sessionId, + user: describeSessionUser(req), + }); + res.json({ active }); + }) +); + +/** + * The editor was closed. The session ends, so the document stops being reported + * as open by somebody who has left. + */ +router.post( + '/onlyoffice/session-end', + asyncHandler(async (req, res) => { + const relativePath = normalizeRelativePath(req.body?.path || ''); + const sessionId = req.body?.sessionId || ''; + if (!relativePath || typeof sessionId !== 'string' || !sessionId) { + throw new ValidationError('A valid ONLYOFFICE editing session is required.'); + } + const context = { user: req.user, guestSession: req.guestSession }; + const { accessInfo, resolved } = await resolvePathWithAccess(context, relativePath); + if (!accessInfo?.canAccess || !accessInfo.canRead) throw new ForbiddenError('Access denied.'); + await getEditorSession(req, sessionId, relativePath); + + onlyofficeActivity.close({ absolutePath: resolved.absolutePath, sessionId }); + await editorSessions.remove(sessionId); + res.json({ ended: true }); + }) +); + +/** + * Rename the open document from the editor's title bar. + * + * The rename itself is the ordinary one, with the ordinary permission checks. + * What is specific here is keeping the editing session pointed at the file + * afterwards: the Document Server holds a token naming the path as it was when + * the editor opened, and returns it unchanged with every save. Left alone, the + * next autosave would recreate the old name beside the new one. + */ +router.post( + '/onlyoffice/rename', + asyncHandler(async (req, res) => { + const relativePath = normalizeRelativePath(req.body?.path || ''); + const sessionId = req.body?.sessionId || ''; + if (!relativePath || typeof sessionId !== 'string' || !sessionId) { + throw new ValidationError('A valid ONLYOFFICE editing session is required.'); + } + + // Only the session that opened this document may rename it from inside the + // editor, and only sessions allowed to write ever get one. + const session = await getEditorSession(req, sessionId, relativePath); + + const parentPath = path.posix.dirname(relativePath); + const renamed = await renameEntry({ + context: { user: req.user, guestSession: req.guestSession }, + parentRelative: parentPath === '.' ? '' : parentPath, + currentName: path.posix.basename(relativePath), + newName: req.body?.newName, + }); + + if (renamed.changed) { + // Three records follow the file: the session decides where a save lands, + // presence decides which row shows as being edited, and the key decides + // whether the people already in the document stay together. + const previousRelativePath = session.relativePath; + await editorSessions.move(sessionId, { + relativePath: renamed.relativePath, + absolutePath: renamed.absolutePath, + }); + onlyofficeActivity.rename({ + from: renamed.previousAbsolutePath, + to: renamed.absolutePath, + }); + await documentKeys.renameDocumentKey({ + from: previousRelativePath, + to: renamed.relativePath, + }); + } + + res.json({ path: renamed.relativePath, name: renamed.name }); + }) +); + // GET /api/onlyoffice/file?path=... router.get( '/onlyoffice/file', @@ -360,30 +618,51 @@ router.post( } // Optionally, resolve from backend token (supports personal paths) - let backendCtx = null; - const backendToken = typeof req.query?.backend === 'string' ? req.query.backend : null; - if (backendToken && onlyoffice.secret) { - try { - const payload = jwt.verify(backendToken, onlyoffice.secret, { - algorithms: ['HS256'], - }); - if (payload && typeof payload === 'object' && payload.absolutePath) { - backendCtx = payload; - } - } catch (e) { - logger.warn({ err: e }, 'ONLYOFFICE backend token verification failed (callback)'); - } - } + const backendCtx = readBackendToken(req); const body = req.body || {}; const status = Number(body.status); + const activityPath = backendCtx?.absolutePath; + + // Status 1 reports the users currently connected to the document. It is + // presence only: this never becomes a filesystem lock, and it expires if + // the Document Server stops sending callbacks. + if (status === 1 && activityPath) { + onlyofficeActivity.updateDocumentServerUsers({ + absolutePath: activityPath, + users: Array.isArray(body.users) ? body.users : [], + }); + } else if ((status === 2 || status === 4) && activityPath) { + onlyofficeActivity.release({ absolutePath: activityPath }); + // The Document Server has let the document go, so its cached copy is + // now the stale one. Dropping the key is what makes the next open fetch + // the saved file instead of that copy. + // + // The session knows where the document is now; the token only knows + // where it was when the editor opened, which a rename since then would + // have made wrong. + const closing = backendCtx?.sessionId + ? await editorSessions.get(backendCtx.sessionId) + : null; + await documentKeys.releaseDocumentKey( + closing?.relativePath || backendCtx?.logicalPath || relativePath + ); + } // See ONLYOFFICE callback statuses: 2 - Save, 6 - Force Save if ((status === 2 || status === 6) && body.url) { // Only the Document Server we handed the document to may be fetched from. const downloadUrl = ensureAllowedDownloadUrl(body.url); let abs = null; - if (backendCtx && typeof backendCtx.absolutePath === 'string' && backendCtx.absolutePath) { - abs = backendCtx.absolutePath; + if (backendCtx) { + // The token stands in for a permission check, so it only counts when + // the session it was issued for was allowed to write. + if (backendCtx.canWrite !== true) { + throw new ForbiddenError('This editing session is read-only.'); + } + await assertShareStillValid(backendCtx); + // Where the document is now, not where it was called when the editor + // opened it. + abs = await resolveSaveTarget(backendCtx); } else { const context = { user: req.user, guestSession: req.guestSession }; const { accessInfo, resolved } = await resolvePathWithAccess(context, relativePath); diff --git a/backend/src/server.js b/backend/src/server.js index c0b8b20c..f7bb861a 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -12,6 +12,10 @@ const searchIndexManager = require('./services/searchIndexManager'); const folderSizeManager = require('./services/folderSizeManager'); const { sweepInterrupted } = require('./services/inFlightFiles'); const trashMaintenance = require('./services/trash/maintenance'); +const { cleanupExpiredShares } = require('./services/sharesService'); +const { cleanupExpiredSessions } = require('./services/guestSessionService'); +const { purgeExpiredDocumentKeys } = require('./services/onlyofficeDocumentKeyService'); +const editorSessions = require('./services/onlyofficeEditorSessionService'); let server = null; @@ -60,10 +64,41 @@ const startServer = async () => { // then keeps each zone within its retention and budget. trashMaintenance.start(); + // Rows that expire and were never swept. The ONLYOFFICE key of a document + // whose browser was closed is one: only a terminal callback released a key, + // so a crash or a restart left the row for good, one for every document ever + // opened. The same sweep takes the two that were already here and had no + // caller at all — `cleanupExpiredShares` and `cleanupExpiredSessions` — so an + // expired share no longer sits on disk indefinitely. + const EXPIRY_SWEEP_INTERVAL_MS = 60 * 60 * 1000; + const sweepExpiredRecords = async () => { + try { + const [shares, sessions, documentKeys] = await Promise.all([ + cleanupExpiredShares(), + cleanupExpiredSessions(), + purgeExpiredDocumentKeys(), + editorSessions.purgeExpired(), + ]); + if (shares || sessions || documentKeys) { + logger.info( + { shares, sessions, documentKeys }, + 'Purged expired shares, guest sessions and ONLYOFFICE document keys' + ); + } + } catch (error) { + logger.warn({ err: error }, 'Expiry sweep failed'); + } + }; + const expirySweep = setInterval(sweepExpiredRecords, EXPIRY_SWEEP_INTERVAL_MS); + // Never keep the process alive just for the sweep. + expirySweep.unref?.(); + void sweepExpiredRecords(); + // Cleanup on process termination const cleanup = () => { logger.info('Shutting down server...'); terminalService.cleanup(); + clearInterval(expirySweep); folderSizeManager.stop(); trashMaintenance.stop(); searchIndexManager.stop(); diff --git a/backend/src/services/db.js b/backend/src/services/db.js index f5aed25f..9c764ca1 100644 --- a/backend/src/services/db.js +++ b/backend/src/services/db.js @@ -38,6 +38,38 @@ const FOLDER_SIZE_INDEX_DDL = ` CREATE INDEX IF NOT EXISTS idx_folder_size_volume ON folder_size_index(volume); `; +// The identity the Document Server files an open document under. Shared by +// everyone who has it open, which is what lets them edit together; see +// onlyofficeDocumentKeyService for why it has to outlive their saves. +// +// Same idempotent treatment as the index above: a /config directory shared with +// a different build may already record a later schema version. +const ONLYOFFICE_DOCUMENT_KEYS_DDL = ` + CREATE TABLE IF NOT EXISTS onlyoffice_document_keys ( + relative_path TEXT PRIMARY KEY, + document_key TEXT NOT NULL, + signature TEXT NOT NULL, + created_at DATETIME, + expires_at DATETIME + ); +`; + +// Where a document is while an editor has it open. Outlives the process on +// purpose: a restart mid-edit used to lose a rename, and the next save then +// recreated the old name beside the new one. +const ONLYOFFICE_EDITOR_SESSIONS_DDL = ` + CREATE TABLE IF NOT EXISTS onlyoffice_editor_sessions ( + id TEXT PRIMARY KEY, + document_key TEXT NOT NULL, + relative_path TEXT NOT NULL, + absolute_path TEXT NOT NULL, + user_id TEXT, + guest_session_id TEXT, + expires_at DATETIME NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_onlyoffice_sessions_expiry ON onlyoffice_editor_sessions(expires_at); +`; + const getDbPath = () => { const configDir = directories.config; // Generic app database for auth, shares, and user settings. @@ -450,6 +482,16 @@ const migrate = (db) => { ); version = 14; } + if (version < 15) { + logger.info('[DB Migration] Migrating to v15: an open document keeps its identity...'); + db.exec(ONLYOFFICE_DOCUMENT_KEYS_DDL); + db.exec(ONLYOFFICE_EDITOR_SESSIONS_DDL); + db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)').run( + 'schema_version', + String(15) + ); + version = 15; + } })(); }; @@ -690,6 +732,18 @@ const getDb = async () => { } catch (err) { logger.warn({ err }, '[DB] Failed to ensure versions tables'); } + // Same reason again: a /config shared with another build may already record a + // later schema version without carrying these tables. + try { + db.exec(ONLYOFFICE_DOCUMENT_KEYS_DDL); + } catch (err) { + logger.warn({ err }, '[DB] Failed to ensure the ONLYOFFICE document key table'); + } + try { + db.exec(ONLYOFFICE_EDITOR_SESSIONS_DDL); + } catch (err) { + logger.warn({ err }, '[DB] Failed to ensure the ONLYOFFICE editor session table'); + } ensureAnonymousUser(db); dbInstance = db; return dbInstance; diff --git a/backend/src/services/onlyofficeActivityService.js b/backend/src/services/onlyofficeActivityService.js new file mode 100644 index 00000000..1d4f9958 --- /dev/null +++ b/backend/src/services/onlyofficeActivityService.js @@ -0,0 +1,232 @@ +const path = require('path'); +const { EventEmitter } = require('events'); + +// This is presence information, not a filesystem lock. Keeping it in memory +// makes it cheap, ephemeral across restarts, and impossible for stale data to +// block a file operation. +const sessionsByPath = new Map(); +const SESSION_TTL_MS = 2 * 60 * 1000; +const DOCUMENT_SERVER_TTL_MS = 15 * 60 * 1000; +const activityEvents = new EventEmitter(); +let activityVersion = 0; +let expirationTimer = null; + +const keyFor = (absolutePath) => path.resolve(absolutePath); + +const notifyActivityChange = () => { + activityVersion += 1; + activityEvents.emit('change', activityVersion); +}; + +const scheduleExpirationCheck = () => { + if (expirationTimer) clearTimeout(expirationTimer); + + let soonest = Infinity; + for (const entry of sessionsByPath.values()) { + for (const session of entry.sessions.values()) { + soonest = Math.min(soonest, session.expiresAt); + } + if (entry.documentServerSeenAt) { + soonest = Math.min(soonest, entry.documentServerSeenAt + DOCUMENT_SERVER_TTL_MS); + } + } + + if (!Number.isFinite(soonest)) { + expirationTimer = null; + return; + } + + expirationTimer = setTimeout( + () => { + expirationTimer = null; + let changed = false; + for (const [key, entry] of sessionsByPath) { + const before = `${entry.sessions.size}:${entry.documentServerUsers.join(',')}`; + const active = cleanup(entry); + const after = `${entry.sessions.size}:${entry.documentServerUsers.join(',')}`; + if (before !== after) changed = true; + if (!active) sessionsByPath.delete(key); + } + if (changed) notifyActivityChange(); + scheduleExpirationCheck(); + }, + Math.max(1, soonest - Date.now()) + ); + expirationTimer.unref?.(); +}; + +const cleanup = (entry, now = Date.now()) => { + for (const [sessionId, session] of entry.sessions) { + if (session.expiresAt <= now) entry.sessions.delete(sessionId); + } + if (entry.documentServerSeenAt && now - entry.documentServerSeenAt > DOCUMENT_SERVER_TTL_MS) { + entry.documentServerUsers = []; + entry.documentServerSeenAt = 0; + } + return entry.sessions.size > 0 || entry.documentServerUsers.length > 0; +}; + +const getEntry = (absolutePath, create = false) => { + const key = keyFor(absolutePath); + let entry = sessionsByPath.get(key); + if (!entry && create) { + entry = { sessions: new Map(), documentServerUsers: [], documentServerSeenAt: 0 }; + sessionsByPath.set(key, entry); + return entry; + } + if (entry && !cleanup(entry)) { + sessionsByPath.delete(key); + return null; + } + return entry; +}; + +/** + * Record that someone has this document open, and keep that record alive. + * + * One call for both because presence starts when the editor reports the + * document open, not when its configuration is requested — a document that + * failed to open used to be shown as being edited, since the marker was placed + * before anyone knew whether the editor would succeed. The first call from a + * session creates the record, later ones only extend it. + * + * Only the first one notifies: a heartbeat every sixty seconds must not wake + * every browser waiting on a presence change. + */ +const touch = ({ absolutePath, sessionId, user }) => { + if (!absolutePath || !sessionId) return false; + const entry = getEntry(absolutePath, true); + const existing = entry.sessions.get(sessionId); + + entry.sessions.set(sessionId, { + userId: existing?.userId ?? (user?.id ? String(user.id) : null), + name: existing?.name || user?.name || 'Utilisateur', + expiresAt: Date.now() + SESSION_TTL_MS, + }); + + if (!existing) notifyActivityChange(); + scheduleExpirationCheck(); + return true; +}; + +/** + * Follow a document that was renamed while open. + * + * Presence is keyed by path, so without this the old name would keep showing + * as being edited until it expired, and the new one would show nothing. + */ +const rename = ({ from, to }) => { + if (!from || !to) return; + const fromKey = keyFor(from); + const entry = sessionsByPath.get(fromKey); + if (!entry) return; + + sessionsByPath.delete(fromKey); + const target = getEntry(to, true); + for (const [sessionId, session] of entry.sessions) { + target.sessions.set(sessionId, session); + } + target.documentServerUsers = [ + ...new Set([...target.documentServerUsers, ...entry.documentServerUsers]), + ]; + target.documentServerSeenAt = Math.max(target.documentServerSeenAt, entry.documentServerSeenAt); + + notifyActivityChange(); + scheduleExpirationCheck(); +}; + +const close = ({ absolutePath, sessionId }) => { + const entry = getEntry(absolutePath); + if (!entry) return; + const removed = entry.sessions.delete(sessionId); + if (!cleanup(entry)) sessionsByPath.delete(keyFor(absolutePath)); + if (removed) notifyActivityChange(); + scheduleExpirationCheck(); +}; + +// The embedded browser can close before Document Server has finished writing +// the document. Only its terminal callback proves that the document is truly +// released, so clear every local presence record for that path at this point. +const release = ({ absolutePath }) => { + const key = absolutePath ? keyFor(absolutePath) : null; + if (!key) return; + + const entry = sessionsByPath.get(key); + if (!entry) return; + + sessionsByPath.delete(key); + notifyActivityChange(); + scheduleExpirationCheck(); +}; + +const updateDocumentServerUsers = ({ absolutePath, users }) => { + if (!absolutePath || !Array.isArray(users)) return; + const entry = getEntry(absolutePath, true); + const nextUsers = [...new Set(users.map((user) => String(user)).filter(Boolean))]; + const changed = + nextUsers.length !== entry.documentServerUsers.length || + nextUsers.some((user, index) => user !== entry.documentServerUsers[index]); + entry.documentServerUsers = nextUsers; + entry.documentServerSeenAt = Date.now(); + if (changed) notifyActivityChange(); + scheduleExpirationCheck(); +}; + +const getVersion = () => activityVersion; + +// Holds one lightweight request until presence changes or the timeout elapses. +// This keeps multiple browser sessions live without re-listing directories on a +// timer when nobody is editing a document. +const waitForChange = (since, timeoutMs = 25_000, signal) => { + if (!Number.isInteger(since) || since !== activityVersion) { + return Promise.resolve({ version: activityVersion, changed: true }); + } + + return new Promise((resolve) => { + let timeout = null; + const finish = (changed) => { + activityEvents.off('change', onChange); + signal?.removeEventListener('abort', onAbort); + if (timeout) clearTimeout(timeout); + resolve({ version: activityVersion, changed }); + }; + const onChange = () => finish(true); + const onAbort = () => finish(false); + if (signal?.aborted) { + finish(false); + return; + } + timeout = setTimeout(() => finish(false), timeoutMs); + timeout.unref?.(); + activityEvents.once('change', onChange); + signal?.addEventListener('abort', onAbort, { once: true }); + }); +}; + +const get = (absolutePath) => { + const entry = getEntry(absolutePath); + if (!entry) return null; + const sessionUsers = Array.from(entry.sessions.values()); + const knownUsers = new Map(sessionUsers.map((user) => [user.userId, user.name])); + const users = [ + ...new Set([...entry.documentServerUsers, ...sessionUsers.map((user) => user.userId)]), + ] + .filter(Boolean) + .map((id) => knownUsers.get(id) || 'Utilisateur'); + return { + active: users.length > 0, + users: [...new Set(users)], + count: users.length, + }; +}; + +module.exports = { + touch, + rename, + close, + release, + updateDocumentServerUsers, + get, + getVersion, + waitForChange, +}; diff --git a/backend/src/services/onlyofficeDocumentKeyService.js b/backend/src/services/onlyofficeDocumentKeyService.js new file mode 100644 index 00000000..ed7ee1ac --- /dev/null +++ b/backend/src/services/onlyofficeDocumentKeyService.js @@ -0,0 +1,165 @@ +const crypto = require('crypto'); + +const { getDb } = require('./db'); +const logger = require('../utils/logger'); + +/** + * The identity the Document Server files a document under. + * + * Two people editing the same document only see each other when they were given + * the same key: the Document Server treats a different key as a different + * document, and opens a second, independent session on the same file. Whoever + * saves last then overwrites the other, with nothing to warn either of them. + * + * The key therefore has to stay the same for as long as anyone has the document + * open — including across the saves those editors are making, which change the + * file and would otherwise change the key. It has to change afterwards, because + * the Document Server caches the prepared document under that key alone and + * would serve the stale copy on the next open. + * + * So: keyed on the file's identity, remembered while the document is in use, and + * dropped when the Document Server reports it has let go. + */ + +// Long enough to cover an editing session, short enough that a key abandoned by +// a crashed browser does not outlive the day. +const KEY_TTL_MS = 12 * 60 * 60 * 1000; + +/** + * What makes this file, opened with this editor, the document it is. + * + * Unchanged from the original inline computation, deliberately: it is what makes + * a key differ after an external replacement, and after opening the same bytes + * with a different editor — a drawing once opened as text kept answering from + * cache long after the mapping was corrected. + */ +const buildSignature = (relativePath, stat, documentType) => + crypto + .createHash('sha256') + .update(relativePath) + .update(String(stat.mtimeMs)) + .update(String(stat.ctimeMs)) + .update(String(stat.size)) + .update(String(documentType)) + .digest('hex'); + +const nowIso = () => new Date().toISOString(); +const expiryIso = () => new Date(Date.now() + KEY_TTL_MS).toISOString(); + +/** + * The key to hand this editor. + * + * `inUse` says whether anyone currently has the document open. It is what + * separates "the file changed because we are editing it" from "the file changed + * while nobody was looking": the first must keep the key, the second must not. + */ +const resolveDocumentKey = async ({ relativePath, stat, documentType, inUse }) => { + const signature = buildSignature(relativePath, stat, documentType); + + let db; + try { + db = await getDb(); + } catch (error) { + // A key that cannot be remembered is still better than no editor at all: + // fall back to the signature, which is what this used to be. + logger.warn({ err: error, path: relativePath }, 'ONLYOFFICE key store unavailable'); + return signature; + } + + const existing = db + .prepare( + 'SELECT document_key, signature, expires_at FROM onlyoffice_document_keys WHERE relative_path = ?' + ) + .get(relativePath); + + if (existing && new Date(existing.expires_at).getTime() > Date.now()) { + // Same file, same editor: nothing has happened that the Document Server's + // cache needs to hear about. + if (existing.signature === signature) { + db.prepare('UPDATE onlyoffice_document_keys SET expires_at = ? WHERE relative_path = ?').run( + expiryIso(), + relativePath + ); + return existing.document_key; + } + + // The file changed under an open document. That is what a save looks like + // from here, so keep the key and let the editors carry on together. + if (inUse) { + db.prepare( + 'UPDATE onlyoffice_document_keys SET signature = ?, expires_at = ? WHERE relative_path = ?' + ).run(signature, expiryIso(), relativePath); + return existing.document_key; + } + } + + // Nobody holds it and it is not what it was: a fresh identity, so the + // Document Server fetches the document again instead of serving its cache. + db.prepare( + `INSERT INTO onlyoffice_document_keys (relative_path, document_key, signature, created_at, expires_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(relative_path) DO UPDATE SET + document_key = excluded.document_key, + signature = excluded.signature, + created_at = excluded.created_at, + expires_at = excluded.expires_at` + ).run(relativePath, signature, signature, nowIso(), expiryIso()); + + return signature; +}; + +/** + * Forget the key for a document the Document Server has released. + * + * Called on its terminal callback. The next open then mints a new key, which is + * what makes the editor fetch the saved document rather than the copy it still + * has cached. + */ +const releaseDocumentKey = async (relativePath) => { + if (!relativePath) return; + try { + const db = await getDb(); + db.prepare('DELETE FROM onlyoffice_document_keys WHERE relative_path = ?').run(relativePath); + } catch (error) { + logger.warn({ err: error, path: relativePath }, 'ONLYOFFICE key could not be released'); + } +}; + +/** + * Follow a document renamed while open, so the editors keep their shared key. + */ +const renameDocumentKey = async ({ from, to }) => { + if (!from || !to) return; + try { + const db = await getDb(); + db.prepare('DELETE FROM onlyoffice_document_keys WHERE relative_path = ?').run(to); + db.prepare('UPDATE onlyoffice_document_keys SET relative_path = ? WHERE relative_path = ?').run( + to, + from + ); + } catch (error) { + logger.warn({ err: error, from, to }, 'ONLYOFFICE key could not follow the rename'); + } +}; + +/** + * Remove the keys nobody can be handed any more. + * + * A key past its expiry is never reused: the next open mints a new one. Its row + * stayed all the same. Only a terminal callback released a key, and a Document + * Server that never sent one — a browser closed on the editor, a server + * restarted — left the row for good, one for every document ever opened. + */ +const purgeExpiredDocumentKeys = async () => { + const db = await getDb(); + return db.prepare('DELETE FROM onlyoffice_document_keys WHERE expires_at <= ?').run(nowIso()) + .changes; +}; + +module.exports = { + buildSignature, + purgeExpiredDocumentKeys, + resolveDocumentKey, + releaseDocumentKey, + renameDocumentKey, +}; diff --git a/backend/src/services/onlyofficeEditorSessionService.js b/backend/src/services/onlyofficeEditorSessionService.js new file mode 100644 index 00000000..33e48e45 --- /dev/null +++ b/backend/src/services/onlyofficeEditorSessionService.js @@ -0,0 +1,108 @@ +const { getDb } = require('./db'); +const logger = require('../utils/logger'); + +/** + * Where a document is, for as long as an editor has it open. + * + * The Document Server is handed a token when the editor opens and returns it + * unchanged with every save, however long the session lasts — so the token says + * where the document *was*, not where it is. Renaming from the title bar makes + * that stale immediately, and a save arriving afterwards would recreate the old + * name beside the new one. + * + * These records are what keep the two in step. They were held in memory, which + * meant a restart mid-edit lost the rename and put the save back under the old + * name — rare, silent, and impossible to explain after the fact. + */ + +const SESSION_TTL_MS = 12 * 60 * 60 * 1000; + +const expiryIso = () => new Date(Date.now() + SESSION_TTL_MS).toISOString(); + +const mapRow = (row) => + row + ? { + key: row.document_key, + relativePath: row.relative_path, + absolutePath: row.absolute_path, + userId: row.user_id, + guestSessionId: row.guest_session_id, + expiresAt: new Date(row.expires_at).getTime(), + } + : null; + +const create = async ({ sessionId, key, relativePath, absolutePath, userId, guestSessionId }) => { + const db = await getDb(); + db.prepare( + `INSERT INTO onlyoffice_editor_sessions + (id, document_key, relative_path, absolute_path, user_id, guest_session_id, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(sessionId, key, relativePath, absolutePath, userId, guestSessionId, expiryIso()); + return sessionId; +}; + +/** + * The session, or null when it has expired or never existed. + * + * Expiry is enforced on read rather than by a timer: a sweep that never runs — + * because the process restarted, say — would otherwise keep stale sessions + * answering for hours. + */ +const get = async (sessionId) => { + if (!sessionId) return null; + const db = await getDb(); + const row = db.prepare('SELECT * FROM onlyoffice_editor_sessions WHERE id = ?').get(sessionId); + if (!row) return null; + + const session = mapRow(row); + if (session.expiresAt <= Date.now()) { + db.prepare('DELETE FROM onlyoffice_editor_sessions WHERE id = ?').run(sessionId); + return null; + } + return session; +}; + +/** Push the expiry back; the document is evidently still open. */ +const touch = async (sessionId) => { + const db = await getDb(); + db.prepare('UPDATE onlyoffice_editor_sessions SET expires_at = ? WHERE id = ?').run( + expiryIso(), + sessionId + ); +}; + +/** Follow the document to its new name, so later saves land on it. */ +const move = async (sessionId, { relativePath, absolutePath }) => { + const db = await getDb(); + db.prepare( + 'UPDATE onlyoffice_editor_sessions SET relative_path = ?, absolute_path = ? WHERE id = ?' + ).run(relativePath, absolutePath, sessionId); +}; + +const remove = async (sessionId) => { + if (!sessionId) return; + const db = await getDb(); + db.prepare('DELETE FROM onlyoffice_editor_sessions WHERE id = ?').run(sessionId); +}; + +const purgeExpired = async () => { + try { + const db = await getDb(); + db.prepare('DELETE FROM onlyoffice_editor_sessions WHERE expires_at <= ?').run( + new Date().toISOString() + ); + } catch (error) { + // Expired rows are refused on read anyway. + logger.debug({ err: error }, 'ONLYOFFICE session purge skipped'); + } +}; + +module.exports = { + create, + get, + touch, + move, + remove, + purgeExpired, + SESSION_TTL_MS, +}; diff --git a/backend/src/services/renameService.js b/backend/src/services/renameService.js new file mode 100644 index 00000000..446f6c9b --- /dev/null +++ b/backend/src/services/renameService.js @@ -0,0 +1,111 @@ +const path = require('path'); +const fs = require('fs/promises'); + +const { combineRelativePath, ensureValidName } = require('../utils/pathUtils'); +const { pathExists } = require('../utils/fsUtils'); +const { ACTIONS, authorizeAndResolve, authorizePath } = require('./authorizationService'); +const { + ValidationError, + ForbiddenError, + NotFoundError, + ConflictError, +} = require('../errors/AppError'); +const versionLifecycle = require('./versions/lifecycle'); + +/** + * Rename an entry within its own folder. + * + * Lives here rather than in the route because two callers need exactly these + * checks in exactly this order: the file browser, and the ONLYOFFICE editor + * renaming the document it has open. The second one has to keep its editing + * session pointing at the file afterwards, which it can only do if the rename + * reports where the file ended up. + * + * @returns {Promise<{absolutePath: string, relativePath: string, name: string, + * previousAbsolutePath: string, changed: boolean}>} + */ +const renameEntry = async ({ context, parentRelative, currentName, newName }) => { + if (typeof currentName !== 'string' || !currentName) { + throw new ValidationError('Original name is required.'); + } + + const { + allowed: parentAllowed, + accessInfo: parentAccess, + resolved: parentResolved, + } = await authorizeAndResolve(context, parentRelative, ACTIONS.write); + if (!parentAllowed || !parentResolved) { + throw new ForbiddenError(parentAccess?.denialReason || 'Destination path is read-only.'); + } + + const currentRelative = combineRelativePath(parentRelative, currentName); + const { + allowed: currentAllowed, + accessInfo: currentAccess, + resolved: currentResolved, + } = await authorizeAndResolve(context, currentRelative, ACTIONS.write); + if (!currentAllowed || !currentResolved) { + throw new ForbiddenError(currentAccess?.denialReason || 'Cannot rename items in this path.'); + } + + const currentAbsolute = currentResolved.absolutePath; + if (!(await pathExists(currentAbsolute))) { + throw new NotFoundError('Item not found.'); + } + + if (typeof newName !== 'string' || !newName) { + throw new ValidationError('A new name is required.'); + } + + let validatedNewName; + try { + validatedNewName = ensureValidName(newName); + } catch (error) { + // ensureValidName throws a plain Error, which the error handler could only + // read as a server fault. A name with a path separator in it is the + // caller's, and answering 500 to it sent everyone looking in the wrong + // place — including the logs, where it appeared as an unhandled failure. + throw new ValidationError(error.message); + } + + // Renaming a file to what it is already called is a no-op, not a conflict. + if (validatedNewName === currentName) { + return { + absolutePath: currentAbsolute, + relativePath: currentRelative, + name: currentName, + previousAbsolutePath: currentAbsolute, + changed: false, + }; + } + + const targetRelative = combineRelativePath(parentRelative, validatedNewName); + const { allowed: targetAllowed, accessInfo: targetAccess } = await authorizePath( + context, + targetRelative, + ACTIONS.write + ); + if (!targetAllowed) { + throw new ForbiddenError(targetAccess?.denialReason || 'Destination path is not accessible.'); + } + + const targetAbsolute = path.join(parentResolved.absolutePath, validatedNewName); + if (await pathExists(targetAbsolute)) { + throw new ConflictError(`The name "${validatedNewName}" is already taken.`); + } + + await fs.rename(currentAbsolute, targetAbsolute); + // The history follows the file, or the histories of everything inside the + // folder, to the new name. + await versionLifecycle.onMoved(currentAbsolute, targetAbsolute); + + return { + absolutePath: targetAbsolute, + relativePath: targetRelative, + name: validatedNewName, + previousAbsolutePath: currentAbsolute, + changed: true, + }; +}; + +module.exports = { renameEntry }; diff --git a/backend/src/services/versions/index.js b/backend/src/services/versions/index.js index 4bee2b46..2a24cb85 100644 --- a/backend/src/services/versions/index.js +++ b/backend/src/services/versions/index.js @@ -287,14 +287,20 @@ const readVersionText = async (context, relativePath, versionId) => { /** * 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 - * version of its own rather than written over what was just restored. + * replaced. Its next save is set aside as a version of its own rather than + * written over what was just restored, and the document is given a fresh + * identity so whoever opens it next gets what was restored rather than the + * Document Server's cached copy of what it replaced. */ -const markRestored = async (absolutePath) => { +const markRestored = async (absolutePath, relative) => { try { const db = await getDb(); const file = await historyOf(db, absolutePath); if (file) store.setRestoredAt(db, file.id, clock.nowIso()); + // Required here rather than at the top: the key service is part of the + // office integration, which reaches back into the versions. + // eslint-disable-next-line global-require + await require('../onlyofficeDocumentKeyService').releaseDocumentKey(relative); } catch (error) { logger.warn({ err: error, absolutePath }, 'A restore could not be announced to open editors'); } @@ -322,7 +328,7 @@ const restoreVersion = async (context, relativePath, versionId) => { if (!located.target.rights.restore) throw new ForbiddenError('This file cannot be changed.'); const result = await writeVersionInto(located, located.target.absolutePath, context); if (result.status !== 'unchanged') { - await markRestored(located.target.absolutePath); + await markRestored(located.target.absolutePath, located.target.relative); } return { status: result.status, path: located.target.relative }; }; @@ -385,7 +391,7 @@ const replaceWithVersion = async (context, relativePath, versionId, { target } = if (!stats?.isFile()) throw new ValidationError('The file to replace must be an existing file.'); const result = await writeVersionInto(located, resolved.absolutePath, context); - if (result.status !== 'unchanged') await markRestored(resolved.absolutePath); + if (result.status !== 'unchanged') await markRestored(resolved.absolutePath, other); return { status: result.status, path: other }; }; diff --git a/backend/src/utils/onlyofficeDocumentTypes.js b/backend/src/utils/onlyofficeDocumentTypes.js new file mode 100644 index 00000000..3539e0c9 --- /dev/null +++ b/backend/src/utils/onlyofficeDocumentTypes.js @@ -0,0 +1,116 @@ +/** + * Which editor the Document Server opens a file with. + * + * These lists are the ones the Document Server itself validates against: it + * ships them as a regular expression in web-apps/apps/api/documents/api.js and + * refuses the config outright when documentType disagrees with the extension. + * They are reproduced here so the disagreement never happens. + * + * Getting this wrong is not a soft failure. Announcing a drawing as a text + * document gets it opened by the wrong editor, which answers "the file content + * does not match the file extension" — true, unhelpful, and several steps away + * from the setting that caused it. That was the case for every extension + * outside the four-or-five-entry lists this used to hold, .odg among them, + * which the Document Server counts as a slide rather than a word document. + * + * Kept beside the route rather than inside it so the mapping can be checked + * against that regular expression in a test. + */ + +const SUPPORTED_SHEET = new Set([ + 'xls', + 'xlsx', + 'ods', + 'csv', + 'tsv', + 'gsheet', + 'xlsm', + 'xlt', + 'xltm', + 'xltx', + 'fods', + 'ots', + 'xlsb', + 'sxc', + 'et', + 'ett', + 'numbers', +]); + +const SUPPORTED_PRESENTATION = new Set([ + 'pps', + 'ppsx', + 'ppt', + 'pptx', + 'odp', + 'gslides', + 'pot', + 'potm', + 'potx', + 'ppsm', + 'pptm', + 'fodp', + 'otp', + 'sxi', + 'dps', + 'dpt', + 'key', + 'odg', +]); + +const SUPPORTED_PDF = new Set(['pdf', 'djvu', 'xps', 'oxps']); + +const SUPPORTED_TEXT = new Set([ + 'doc', + 'docx', + 'odt', + 'gdoc', + 'txt', + 'rtf', + 'mht', + 'htm', + 'html', + 'mhtml', + 'epub', + 'docm', + 'dot', + 'dotm', + 'dotx', + 'fodt', + 'ott', + 'fb2', + 'xml', + 'oform', + 'docxf', + 'sxw', + 'stw', + 'wps', + 'wpt', + 'pages', + 'hwp', + 'hwpx', + 'md', + 'hml', +]); + +const SUPPORTED_DIAGRAM = new Set(['vsdx', 'vssx', 'vstx', 'vsdm', 'vssm', 'vstm']); + +/** + * @param {string} ext lowercase extension, without the dot + * @returns {string|null} the documentType to declare, or null when the Document + * Server has no editor for it. Null rather than a guess: 'word' as a catch-all + * is exactly what turned an unsupported extension into an error raised by the + * editor instead of a refusal here. + */ +const getDocumentType = (ext) => { + if (SUPPORTED_SHEET.has(ext)) return 'cell'; + if (SUPPORTED_PRESENTATION.has(ext)) return 'slide'; + if (SUPPORTED_PDF.has(ext)) return 'pdf'; + if (SUPPORTED_TEXT.has(ext)) return 'word'; + if (SUPPORTED_DIAGRAM.has(ext)) return 'diagram'; + return null; +}; + +module.exports = { + getDocumentType, +}; diff --git a/backend/tests/routes/onlyoffice-session.test.js b/backend/tests/routes/onlyoffice-session.test.js new file mode 100644 index 00000000..876a1eda --- /dev/null +++ b/backend/tests/routes/onlyoffice-session.test.js @@ -0,0 +1,413 @@ +import fs from 'node:fs/promises'; +import http from 'node:http'; +import path from 'node:path'; +import express from 'express'; +import jwt from 'jsonwebtoken'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * The identity the Document Server files an open document under, and the record + * of where that document is. + * + * Two people editing the same document only see each other when they were given + * the same key: a different key is a different document to the Document Server, + * which opens a second, independent session on the same file. Whoever saves + * last then overwrites the other, with nothing to warn either of them. The key + * used to be recomputed from the file's own modification time on every open, so + * it changed under the people already editing — every save of theirs split the + * session. + * + * It also has to change once everybody has left, because the Document Server + * caches the prepared document under that key and would otherwise serve the + * stale copy on the next open. + * + * The session is the other half: the Document Server is handed a token when the + * editor opens and returns it unchanged with every save, so the token says + * where the document *was*. Renaming from the title bar makes that stale at + * once, and a save arriving afterwards would recreate the old name beside the + * new one. + */ + +const SECRET = 'onlyoffice-session-secret'; +const DOCUMENT = 'Projects/report.docx'; + +let env; +let app; +let users; +let documentServer; +let serverUrl; +let saved; + +const load = (relative) => require(modulePath(relative)); +const volume = (...segments) => path.join(env.volumeDir, ...segments); + +beforeEach(async () => { + saved = { content: 'edited' }; + documentServer = http.createServer((_req, res) => { + res.setHeader('Content-Type', 'application/octet-stream'); + res.end(saved.content); + }); + await new Promise((resolve) => documentServer.listen(0, '127.0.0.1', resolve)); + serverUrl = `http://127.0.0.1:${documentServer.address().port}`; + + env = await setupTestEnv({ + tag: 'onlyoffice-session-', + env: { + PUBLIC_URL: 'https://files.example.com', + ONLYOFFICE_URL: serverUrl, + ONLYOFFICE_SECRET: SECRET, + }, + }); + + 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']) }; + + await fs.mkdir(volume('Projects'), { recursive: true }); + await fs.writeFile(volume('Projects', 'report.docx'), 'first'); + + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + const who = req.get('x-test-user') || 'alice'; + req.user = users[who]; + next(); + }); + app.use('/api', load('src/routes/onlyoffice')); + app.use(load('src/middleware/errorHandler').errorHandler); +}); + +afterEach(async () => { + load('src/services/trash/maintenance').stop(); + await new Promise((resolve) => documentServer.close(resolve)); + await env.cleanup(); +}); + +const openDocument = (file = DOCUMENT, who = 'alice') => + request(app).post('/api/onlyoffice/config').set('x-test-user', who).send({ path: file }); + +const heartbeat = (file, sessionId, who = 'alice') => + request(app) + .post('/api/onlyoffice/session-heartbeat') + .set('x-test-user', who) + .send({ path: file, sessionId }); + +/** The backend token the config put on the callback URL, as the Document Server returns it. */ +const backendTokenOf = (body) => { + const callbackUrl = new URL(body.config.editorConfig.callbackUrl); + return callbackUrl.searchParams.get('backend'); +}; + +const callback = (body, { backend, file = DOCUMENT } = {}) => { + const call = request(app) + .post('/api/onlyoffice/callback') + .query({ path: file, ...(backend ? { backend } : {}) }) + .set('Authorization', `Bearer ${jwt.sign({ any: true }, SECRET)}`); + return call.send({ url: `${serverUrl}/saved.docx`, key: 'session-key', ...body }); +}; + +describe('the key a document is opened under', () => { + it('is the same for everybody already in the document', async () => { + const first = await openDocument(); + expect(first.status).toBe(200); + await heartbeat(DOCUMENT, first.body.editorSessionId); + + const second = await openDocument(DOCUMENT, 'bob'); + + expect(second.body.config.document.key).toBe(first.body.config.document.key); + }); + + /** + * A save changes the file, which is what used to change the key — splitting + * the very session that made the save. Status 6 is a save with the document + * still open, which is what an autosave and the editor's own Save send. + */ + it('survives a save made by the people editing', async () => { + const first = await openDocument(); + await heartbeat(DOCUMENT, first.body.editorSessionId); + await callback({ status: 6 }, { backend: backendTokenOf(first.body) }); + + const again = await openDocument(DOCUMENT, 'bob'); + + expect(again.body.config.document.key).toBe(first.body.config.document.key); + expect(await fs.readFile(volume('Projects', 'report.docx'), 'utf8')).toBe('edited'); + }); + + /** Nobody is in it, and it is not what it was: the cached copy is the stale one. */ + it('changes when the file changed while nobody had it open', async () => { + const first = await openDocument(); + await fs.writeFile(volume('Projects', 'report.docx'), 'changed underneath'); + + const again = await openDocument(); + + expect(again.body.config.document.key).not.toBe(first.body.config.document.key); + }); + + it('is dropped once the Document Server says it has let go', async () => { + const first = await openDocument(); + await heartbeat(DOCUMENT, first.body.editorSessionId); + const backend = backendTokenOf(first.body); + + // Status 4: closed with no changes. The Document Server keeps its prepared + // copy under the key, so the key must not be handed out again. + await callback({ status: 4 }, { backend }); + await fs.writeFile(volume('Projects', 'report.docx'), 'changed while closed'); + + const again = await openDocument(); + + expect(again.body.config.document.key).not.toBe(first.body.config.document.key); + }); + + /** Putting a version back is a change the editors' cached copy knows nothing about. */ + it('is dropped when a version is restored over the file', async () => { + const first = await openDocument(); + const keyBefore = first.body.config.document.key; + + const versions = load('src/services/versions/operations'); + await versions.saveFile( + volume('Projects', 'report.docx'), + (temporary) => fs.writeFile(temporary, 'second', { flag: 'wx' }), + { purpose: 'editor', source: 'editor', explicit: true } + ); + const service = load('src/services/versions'); + const context = { user: users.alice }; + const [version] = (await service.listVersions(context, DOCUMENT)).versions; + await service.restoreVersion(context, DOCUMENT, version.id); + + const again = await openDocument(); + + expect(again.body.config.document.key).not.toBe(keyBefore); + }); +}); + +describe('the token the editor is given', () => { + it('is refused when it is a Document Server token signed with the same secret', async () => { + const opened = await openDocument(); + // Exactly what the Document Server signs with: the same secret, no type. + const impostor = jwt.sign({ absolutePath: volume('Projects', 'report.docx') }, SECRET); + + const response = await callback({ status: 2 }, { backend: impostor }); + + // The callback falls back to resolving the path itself, so the forged + // absolute path is never the one written to. + expect(response.body).toEqual({ error: 0 }); + expect(await fs.readFile(volume('Projects', 'report.docx'), 'utf8')).toBe('edited'); + expect(opened.body.editorSessionId).toBeTruthy(); + }); + + it('expires', async () => { + const opened = await openDocument(); + const payload = jwt.decode(backendTokenOf(opened.body)); + + expect(payload.exp).toBeTruthy(); + expect(payload.exp - payload.iat).toBe(12 * 60 * 60); + }); + + it('says whether the session it was issued for may write', async () => { + const opened = await openDocument(); + + expect(jwt.decode(backendTokenOf(opened.body)).canWrite).toBe(true); + }); + + /** + * A read-only folder used to hand out an editing session all the same: the + * decision looked only at the requested mode and at whether the document came + * through a read-only share, never at the location's own rights. + */ + it('refuses to write a document in a folder this account may only read', async () => { + await load('src/services/accessControlService').setRules([ + { path: 'Projects', permissions: 'ro', recursive: true }, + ]); + + const opened = await openDocument(); + + expect(opened.body.editorSessionId).toBeNull(); + expect(opened.body.config.document.permissions.edit).toBe(false); + expect(jwt.decode(backendTokenOf(opened.body)).canWrite).toBe(false); + + const response = await callback({ status: 2 }, { backend: backendTokenOf(opened.body) }); + + expect(response.body).toEqual({ error: 1 }); + expect(await fs.readFile(volume('Projects', 'report.docx'), 'utf8')).toBe('first'); + }); + + /** A viewer's token must not be enough to write the document. */ + it('refuses a save made with a read-only session', async () => { + const opened = await request(app) + .post('/api/onlyoffice/config') + .send({ path: DOCUMENT, mode: 'view' }); + + expect(opened.body.editorSessionId).toBeNull(); + const readOnly = jwt.decode(backendTokenOf(opened.body)); + expect(readOnly.canWrite).toBe(false); + + const response = await callback({ status: 2 }, { backend: backendTokenOf(opened.body) }); + + // Per the ONLYOFFICE contract a refusal is reported as error 1, not a status. + expect(response.body).toEqual({ error: 1 }); + expect(await fs.readFile(volume('Projects', 'report.docx'), 'utf8')).toBe('first'); + }); +}); + +describe('the editor it is opened with', () => { + it('refuses a file it has no editor for, rather than guessing', async () => { + await fs.writeFile(volume('Projects', 'drawing.zzz'), 'not a document'); + + const response = await openDocument('Projects/drawing.zzz'); + + expect(response.status).toBe(400); + expect(response.body.error.message).toMatch(/no editor for \.zzz/i); + }); + + it('opens a drawing as a presentation, which is what the Document Server calls it', async () => { + await fs.writeFile(volume('Projects', 'plan.odg'), 'drawing'); + + const response = await openDocument('Projects/plan.odg'); + + expect(response.body.config.documentType).toBe('slide'); + }); +}); + +describe('renaming the document from the title bar', () => { + const rename = (newName, sessionId, file = DOCUMENT, who = 'alice') => + request(app) + .post('/api/onlyoffice/rename') + .set('x-test-user', who) + .send({ path: file, sessionId, newName }); + + it('moves the file and keeps the session on it', async () => { + const opened = await openDocument(); + + const renamed = await rename('quarterly.docx', opened.body.editorSessionId); + + expect(renamed.status).toBe(200); + expect(renamed.body.path).toBe('Projects/quarterly.docx'); + expect(await fs.readFile(volume('Projects', 'quarterly.docx'), 'utf8')).toBe('first'); + }); + + /** + * The save arrives with the token minted before the rename, which still names + * the old path. Left alone it recreated the old name beside the new one. + */ + it('lands a later save on the new name, not the old one', async () => { + const opened = await openDocument(); + const backend = backendTokenOf(opened.body); + await rename('quarterly.docx', opened.body.editorSessionId); + + await callback({ status: 2 }, { backend }); + + expect(await fs.readFile(volume('Projects', 'quarterly.docx'), 'utf8')).toBe('edited'); + await expect(fs.stat(volume('Projects', 'report.docx'))).rejects.toThrow(); + }); + + it('keeps the people already editing together', async () => { + const opened = await openDocument(); + await heartbeat(DOCUMENT, opened.body.editorSessionId); + await rename('quarterly.docx', opened.body.editorSessionId); + + const joining = await openDocument('Projects/quarterly.docx', 'bob'); + + expect(joining.body.config.document.key).toBe(opened.body.config.document.key); + }); + + it('is refused without the session that opened the document', async () => { + await openDocument(); + + expect((await rename('quarterly.docx', 'not-a-session')).status).toBe(403); + expect(await fs.readFile(volume('Projects', 'report.docx'), 'utf8')).toBe('first'); + }); + + it("is refused to somebody else's session", async () => { + const opened = await openDocument(); + + const response = await rename('quarterly.docx', opened.body.editorSessionId, DOCUMENT, 'bob'); + + expect(response.status).toBe(403); + }); + + /** A name with a separator in it is the caller's mistake, not a server fault. */ + it('answers a bad name as a bad request', async () => { + const opened = await openDocument(); + + const response = await rename('../escape.docx', opened.body.editorSessionId); + + expect(response.status).toBe(400); + }); +}); + +describe('the session', () => { + it('reports the document open once the editor says it is ready', async () => { + const opened = await openDocument(); + + const beat = await heartbeat(DOCUMENT, opened.body.editorSessionId); + + expect(beat.status).toBe(200); + expect(beat.body.active).toBe(true); + }); + + it('is not open merely because a configuration was asked for', async () => { + await openDocument(); + + const activity = load('src/services/onlyofficeActivityService'); + + expect(activity.get(volume('Projects', 'report.docx'))?.active).toBeFalsy(); + }); + + it("refuses somebody else's session", async () => { + const opened = await openDocument(); + + const beat = await heartbeat(DOCUMENT, opened.body.editorSessionId, 'bob'); + + expect(beat.status).toBe(403); + }); + + it('ends, and the document stops being reported as open', async () => { + const opened = await openDocument(); + await heartbeat(DOCUMENT, opened.body.editorSessionId); + + const ended = await request(app) + .post('/api/onlyoffice/session-end') + .send({ path: DOCUMENT, sessionId: opened.body.editorSessionId }); + + expect(ended.status).toBe(200); + const activity = load('src/services/onlyofficeActivityService'); + expect(activity.get(volume('Projects', 'report.docx'))?.active).toBeFalsy(); + // And it is gone: a second end is no longer a session anybody holds. + expect( + ( + await request(app) + .post('/api/onlyoffice/session-end') + .send({ path: DOCUMENT, sessionId: opened.body.editorSessionId }) + ).status + ).toBe(403); + }); + + /** + * Only a terminal callback released a key, so a browser closed on the editor + * — or a restart — left the row for good, one for every document ever opened. + */ + it('has its key swept once it has expired', async () => { + await openDocument(); + const db = await load('src/services/db').getDb(); + db.prepare('UPDATE onlyoffice_document_keys SET expires_at = ?').run( + new Date(Date.now() - 1000).toISOString() + ); + + const purged = await load( + 'src/services/onlyofficeDocumentKeyService' + ).purgeExpiredDocumentKeys(); + + expect(purged).toBe(1); + expect(db.prepare('SELECT COUNT(*) AS n FROM onlyoffice_document_keys').get().n).toBe(0); + }); +}); diff --git a/frontend/src/api/onlyoffice.api.js b/frontend/src/api/onlyoffice.api.js index b679de5d..bd0c5d60 100644 --- a/frontend/src/api/onlyoffice.api.js +++ b/frontend/src/api/onlyoffice.api.js @@ -1,6 +1,6 @@ // /api/onlyoffice.api.js -import { requestJson, normalizePath } from './http'; +import { buildUrl, requestJson, normalizePath } from './http'; export async function fetchOnlyOfficeConfig(path, mode = 'edit') { const normalizedPath = normalizePath(path || ''); @@ -11,3 +11,75 @@ export async function fetchOnlyOfficeConfig(path, mode = 'edit') { body: JSON.stringify({ path: normalizedPath, mode }), }); } + +/** + * The document is really open, and goes on being open. + * + * Sent once ONLYOFFICE reports the document ready, then on a timer: the + * configuration alone says nothing about whether the document opened, so + * presence starts here rather than there. + */ +export async function heartbeatOnlyOfficeSession(path, { sessionId } = {}) { + const normalizedPath = normalizePath(path || ''); + if (!normalizedPath || !sessionId) return { active: false }; + + return requestJson('/api/onlyoffice/session-heartbeat', { + method: 'POST', + body: JSON.stringify({ path: normalizedPath, sessionId }), + }); +} + +/** + * The editing session is over. + * + * `beacon` is for a page being unloaded. A tab being closed gives one + * synchronous moment, and an ordinary request started in it is cancelled along + * with everything else — `sendBeacon` hands the request to the browser, which + * sends it after the page is gone, with the same cookies. `keepalive` is the + * same idea through `fetch`, and is what answers when a browser has no + * `sendBeacon`; it is also what makes the reply readable, which is why the + * preview — which has time — uses it. + */ +export async function endOnlyOfficeSession(path, { sessionId, beacon = false } = {}) { + const normalizedPath = normalizePath(path || ''); + if (!normalizedPath || !sessionId) return null; + const body = JSON.stringify({ path: normalizedPath, sessionId }); + + if (beacon && typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') { + try { + const handedOver = navigator.sendBeacon( + buildUrl('/api/onlyoffice/session-end'), + // Typed, because the server reads JSON bodies and nothing else: a + // beacon sent as text/plain arrives with an empty body. + new Blob([body], { type: 'application/json' }) + ); + if (handedOver) return null; + } catch (_) { + // A browser that refused the beacon still has the request below. + } + } + + return requestJson('/api/onlyoffice/session-end', { + method: 'POST', + body, + keepalive: true, + }); +} + +/** + * Rename the open document from the editor's title bar. + * + * The session id goes with it so the server can keep that session pointing at + * the file; a save arriving afterwards would otherwise recreate the old name. + */ +export async function renameOnlyOfficeDocument(path, { sessionId, newName } = {}) { + const normalizedPath = normalizePath(path || ''); + if (!normalizedPath || !sessionId || !newName) { + throw new Error('Path, session and new name are required.'); + } + + return requestJson('/api/onlyoffice/rename', { + method: 'POST', + body: JSON.stringify({ path: normalizedPath, sessionId, newName }), + }); +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 45b62d3c..f4610a91 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -918,5 +918,10 @@ "action": "Die Aktion für diese Version ist fehlgeschlagen" }, "mark": "{count} frühere Version | {count} frühere Versionen" + }, + "onlyoffice": { + "renamedHeading": "Umbenannt", + "renamedBody": "Das Dokument heißt jetzt {name}.", + "renameFailed": "Umbenennen in {name} nicht möglich" } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 358f8398..d8ac9046 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -918,5 +918,10 @@ "action": "The action on this version failed" }, "mark": "{count} earlier version | {count} earlier versions" + }, + "onlyoffice": { + "renamedHeading": "Renamed", + "renamedBody": "The document is now called {name}.", + "renameFailed": "Could not rename to {name}" } } diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 68c6eb15..21738695 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -918,5 +918,10 @@ "action": "La acción sobre esta versión falló" }, "mark": "{count} versión anterior | {count} versiones anteriores" + }, + "onlyoffice": { + "renamedHeading": "Renombrado", + "renamedBody": "El documento se llama ahora {name}.", + "renameFailed": "No se ha podido renombrar a {name}" } } diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 19f40c75..68800d10 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -918,5 +918,10 @@ "action": "L'action sur cette version a échoué" }, "mark": "{count} version antérieure | {count} versions antérieures" + }, + "onlyoffice": { + "renamedHeading": "Renommé", + "renamedBody": "Le document s’appelle maintenant {name}.", + "renameFailed": "Impossible de renommer en {name}" } } diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index 8f6614af..1bd8701b 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -918,5 +918,10 @@ "action": "इस संस्करण पर क्रिया विफल रही" }, "mark": "{count} पुराना संस्करण | {count} पुराने संस्करण" + }, + "onlyoffice": { + "renamedHeading": "नाम बदला गया", + "renamedBody": "दस्तावेज़ का नाम अब {name} है।", + "renameFailed": "{name} में नाम नहीं बदला जा सका" } } diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 3ea8b631..cd5c8fd4 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -918,5 +918,10 @@ "action": "L'azione su questa versione non è riuscita" }, "mark": "{count} versione precedente | {count} versioni precedenti" + }, + "onlyoffice": { + "renamedHeading": "Rinominato", + "renamedBody": "Il documento si chiama ora {name}.", + "renameFailed": "Impossibile rinominare in {name}" } } diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index cc806969..cf169e4a 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -918,5 +918,10 @@ "action": "이 버전에 대한 작업이 실패했습니다" }, "mark": "이전 버전 {count}개" + }, + "onlyoffice": { + "renamedHeading": "이름 변경됨", + "renamedBody": "문서 이름이 {name}(으)로 바뀌었습니다.", + "renameFailed": "{name}(으)로 이름을 바꾸지 못했습니다" } } diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index d3cb1c0a..50da957a 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -918,5 +918,10 @@ "action": "De actie op deze versie is mislukt" }, "mark": "{count} eerdere versie | {count} eerdere versies" + }, + "onlyoffice": { + "renamedHeading": "Hernoemd", + "renamedBody": "Het document heet nu {name}.", + "renameFailed": "Hernoemen naar {name} is mislukt" } } diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index c2ea89e8..547bc6f8 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -918,5 +918,10 @@ "action": "Działanie na tej wersji nie powiodło się" }, "mark": "{count} wcześniejsza wersja | {count} wcześniejsze wersje | {count} wcześniejszych wersji" + }, + "onlyoffice": { + "renamedHeading": "Zmieniono nazwę", + "renamedBody": "Dokument nazywa się teraz {name}.", + "renameFailed": "Nie można zmienić nazwy na {name}" } } diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index 0d80726c..d4f3fff8 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -918,5 +918,10 @@ "action": "A ação nesta versão falhou" }, "mark": "{count} versão anterior | {count} versões anteriores" + }, + "onlyoffice": { + "renamedHeading": "Renomeado", + "renamedBody": "O documento agora se chama {name}.", + "renameFailed": "Não foi possível renomear para {name}" } } diff --git a/frontend/src/i18n/locales/ro.json b/frontend/src/i18n/locales/ro.json index 1c588ef1..5e4c9347 100644 --- a/frontend/src/i18n/locales/ro.json +++ b/frontend/src/i18n/locales/ro.json @@ -918,5 +918,10 @@ "action": "Acțiunea asupra acestei versiuni a eșuat" }, "mark": "{count} versiune anterioară | {count} versiuni anterioare" + }, + "onlyoffice": { + "renamedHeading": "Redenumit", + "renamedBody": "Documentul se numește acum {name}.", + "renameFailed": "Nu s-a putut redenumi în {name}" } } diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index a5d67553..1f7f89fb 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -918,5 +918,10 @@ "action": "Не удалось выполнить действие с версией" }, "mark": "{count} предыдущая версия | {count} предыдущие версии | {count} предыдущих версий" + }, + "onlyoffice": { + "renamedHeading": "Переименовано", + "renamedBody": "Документ теперь называется {name}.", + "renameFailed": "Не удалось переименовать в {name}" } } diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index c4c4d90c..58a56503 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -918,5 +918,10 @@ "action": "Åtgärden på den här versionen misslyckades" }, "mark": "{count} tidigare version | {count} tidigare versioner" + }, + "onlyoffice": { + "renamedHeading": "Namnet ändrat", + "renamedBody": "Dokumentet heter nu {name}.", + "renameFailed": "Det gick inte att byta namn till {name}" } } diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 6aedc5d5..304f9ce2 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -918,5 +918,10 @@ "action": "对此版本的操作失败" }, "mark": "{count} 个历史版本" + }, + "onlyoffice": { + "renamedHeading": "已重命名", + "renamedBody": "文档现在名为 {name}。", + "renameFailed": "无法重命名为 {name}" } } diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 516a5d46..0faee896 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -918,5 +918,10 @@ "action": "對此版本的動作失敗" }, "mark": "{count} 個舊版本" + }, + "onlyoffice": { + "renamedHeading": "已重新命名", + "renamedBody": "文件現在名為 {name}。", + "renameFailed": "無法重新命名為 {name}" } } diff --git a/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue b/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue index 126223f8..d4f33527 100644 --- a/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue +++ b/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue @@ -25,9 +25,17 @@ + + diff --git a/frontend/src/composables/useFileDragDrop.js b/frontend/src/composables/useFileDragDrop.js index a8dfd067..13d7484d 100644 --- a/frontend/src/composables/useFileDragDrop.js +++ b/frontend/src/composables/useFileDragDrop.js @@ -1,6 +1,7 @@ import { ref } from 'vue'; import { useFileStore } from '@/stores/fileStore'; import { moveItems, normalizePath } from '@/api'; +import { useOnlyOfficeTransferConfirm } from '@/composables/useOnlyOfficeTransferConfirm'; import { useInputMode } from '@/composables/useInputMode'; /** @@ -9,6 +10,7 @@ import { useInputMode } from '@/composables/useInputMode'; */ export function useFileDragDrop() { const fileStore = useFileStore(); + const onlyOfficeTransferConfirm = useOnlyOfficeTransferConfirm(); const { isTouchDevice } = useInputMode(); const isDraggingOver = ref(false); const dragOverTarget = ref(null); @@ -23,7 +25,9 @@ export function useFileDragDrop() { const types = event?.dataTransfer?.types; if (!types) return false; // Our internal drags set application/json and a text/plain fallback for Safari. - return Array.from(types).includes('application/json') || Array.from(types).includes('text/plain'); + return ( + Array.from(types).includes('application/json') || Array.from(types).includes('text/plain') + ); }; const serializeItems = (items) => @@ -254,6 +258,12 @@ export function useFileDragDrop() { return; } + // Moving a document somebody has open in an editor is allowed, and worth + // asking about: the editor will write where the file used to be, and the + // save that follows lands under the old name. + const confirmed = await onlyOfficeTransferConfirm.requestConfirmation(draggedItems); + if (!confirmed) return; + try { // Prepare payload for moveItems API const movePayload = serializeItems(draggedItems); diff --git a/frontend/src/composables/useOnlyOfficeActivity.js b/frontend/src/composables/useOnlyOfficeActivity.js new file mode 100644 index 00000000..339b27c1 --- /dev/null +++ b/frontend/src/composables/useOnlyOfficeActivity.js @@ -0,0 +1,86 @@ +import { waitForOnlyOfficeActivityVersion } from '@/api'; + +/** + * Keeping the "being edited" marks in a listing current. + * + * The server holds the request open until somebody joins or leaves a document, + * so this costs nothing while nothing happens — which is what makes presence + * affordable to show at all. A hidden tab stops asking entirely. + * + * @param {object} options + * @param {object} options.featuresStore + * @param {() => Promise} options.refresh list the current folder again + */ +export const useOnlyOfficeActivity = ({ featuresStore, refresh }) => { + let activityVersion = null; + let started = false; + let pollController = null; + let visibilityHandlerBound = false; + + const waitForVisibility = () => + new Promise((resolve) => { + if (typeof document === 'undefined' || document.visibilityState !== 'hidden') { + resolve(); + return; + } + document.addEventListener('visibilitychange', resolve, { once: true }); + }); + + const start = async () => { + if (started || typeof window === 'undefined') return; + + // The route only exists where ONLYOFFICE is configured, so asking for it + // anywhere else is a 404 — answered at once, retried a second later, for as + // long as the tab stays open. Every one of them is logged server-side with + // a stack trace, and the mark it feeds cannot show anything without a + // Document Server anyway. + await featuresStore.ensureLoaded(); + if (!featuresStore.onlyofficeEnabled) return; + // Another navigation may have got here while features were loading. + if (started) return; + started = true; + + const poll = async () => { + while (started) { + await waitForVisibility(); + if (!started) return; + + const controller = new AbortController(); + pollController = controller; + try { + const response = await waitForOnlyOfficeActivityVersion(activityVersion, { + signal: controller.signal, + }); + const nextVersion = Number(response?.version); + const changed = + Number.isInteger(nextVersion) && + activityVersion !== null && + nextVersion !== activityVersion; + if (Number.isInteger(nextVersion)) activityVersion = nextVersion; + if (changed) await refresh(); + } catch (error) { + if (error?.name !== 'AbortError') { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } finally { + if (pollController === controller) pollController = null; + } + } + }; + + if (!visibilityHandlerBound && typeof document !== 'undefined') { + visibilityHandlerBound = true; + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') pollController?.abort(); + }); + } + void poll(); + }; + + const stop = () => { + started = false; + pollController?.abort(); + }; + + return { start, stop }; +}; diff --git a/frontend/src/composables/useOnlyOfficeTransferConfirm.js b/frontend/src/composables/useOnlyOfficeTransferConfirm.js new file mode 100644 index 00000000..a2d2ee0b --- /dev/null +++ b/frontend/src/composables/useOnlyOfficeTransferConfirm.js @@ -0,0 +1,57 @@ +import { computed, ref } from 'vue'; + +let instance = null; + +export function useOnlyOfficeTransferConfirm() { + if (instance) return instance; + + const isOpen = ref(false); + const pendingItems = ref([]); + let resolvePending = null; + + const activeItems = computed(() => + pendingItems.value.filter((item) => item?.onlyofficeActivity?.active) + ); + + const requestConfirmation = (items) => { + const itemsBeingEdited = (Array.isArray(items) ? items : []).filter( + (item) => item?.onlyofficeActivity?.active + ); + if (itemsBeingEdited.length === 0) return Promise.resolve(true); + + // A second question replaces the one on screen, and the first must not be + // left unanswered: whoever is waiting on it is holding a transfer open, + // for as long as the tab lives. It is answered as refused — nobody said + // yes to it, and a transfer that does not happen is the safe half of the + // question this asks. + if (resolvePending) settle(false); + + pendingItems.value = itemsBeingEdited; + isOpen.value = true; + + return new Promise((resolve) => { + resolvePending = resolve; + }); + }; + + const settle = (confirmed) => { + if (!resolvePending) return; + const resolve = resolvePending; + resolvePending = null; + isOpen.value = false; + pendingItems.value = []; + resolve(confirmed); + }; + + const cancel = () => settle(false); + const confirm = () => settle(true); + + instance = { + isOpen, + activeItems, + requestConfirmation, + cancel, + confirm, + }; + return instance; +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index f4610a91..8ff47632 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Umbenannt", "renamedBody": "Das Dokument heißt jetzt {name}.", - "renameFailed": "Umbenennen in {name} nicht möglich" + "renameFailed": "Umbenennen in {name} nicht möglich", + "andOthers": "und {count} weitere", + "transferHeading": "Datei wird bearbeitet", + "transferBody": "Diese Datei ist in OnlyOffice geöffnet. Fortfahren kann einen laufenden Speichervorgang stören. Möchten Sie fortfahren?", + "transferCancel": "Abbrechen", + "transferConfirm": "Fortfahren", + "editingBy": "Wird in ONLYOFFICE bearbeitet von: {names}", + "editingNow": "Wird gerade in ONLYOFFICE bearbeitet" } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index d8ac9046..7cddde2b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Renamed", "renamedBody": "The document is now called {name}.", - "renameFailed": "Could not rename to {name}" + "renameFailed": "Could not rename to {name}", + "andOthers": "and {count} more", + "transferHeading": "File is being edited", + "transferBody": "This file is open in OnlyOffice. Continuing may disturb a save in progress. Do you want to continue?", + "transferCancel": "Cancel", + "transferConfirm": "Continue", + "editingBy": "Being edited in ONLYOFFICE by {names}", + "editingNow": "Being edited in ONLYOFFICE" } } diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 21738695..28e9684f 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Renombrado", "renamedBody": "El documento se llama ahora {name}.", - "renameFailed": "No se ha podido renombrar a {name}" + "renameFailed": "No se ha podido renombrar a {name}", + "andOthers": "y {count} más", + "transferHeading": "El archivo se está editando", + "transferBody": "Este archivo está abierto en OnlyOffice. Continuar puede interrumpir un guardado en curso. ¿Quieres continuar?", + "transferCancel": "Cancelar", + "transferConfirm": "Continuar", + "editingBy": "Se está editando en ONLYOFFICE: {names}", + "editingNow": "Se está editando en ONLYOFFICE" } } diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 68800d10..11a36f2e 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Renommé", "renamedBody": "Le document s’appelle maintenant {name}.", - "renameFailed": "Impossible de renommer en {name}" + "renameFailed": "Impossible de renommer en {name}", + "andOthers": "et {count} autre(s)", + "transferHeading": "Fichier en cours d’édition", + "transferBody": "Ce fichier est ouvert dans OnlyOffice. Continuer peut perturber une sauvegarde en cours. Voulez-vous continuer ?", + "transferCancel": "Annuler", + "transferConfirm": "Continuer", + "editingBy": "Édition en cours dans ONLYOFFICE : {names}", + "editingNow": "Édition en cours dans ONLYOFFICE" } } diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index 1bd8701b..40fed6c6 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "नाम बदला गया", "renamedBody": "दस्तावेज़ का नाम अब {name} है।", - "renameFailed": "{name} में नाम नहीं बदला जा सका" + "renameFailed": "{name} में नाम नहीं बदला जा सका", + "andOthers": "और {count} अन्य", + "transferHeading": "फ़ाइल संपादित की जा रही है", + "transferBody": "यह फ़ाइल OnlyOffice में खुली है। जारी रखने से चल रही सेव प्रक्रिया बाधित हो सकती है। क्या आप जारी रखना चाहते हैं?", + "transferCancel": "रद्द करें", + "transferConfirm": "जारी रखें", + "editingBy": "ONLYOFFICE में संपादित किया जा रहा है: {names}", + "editingNow": "ONLYOFFICE में संपादित किया जा रहा है" } } diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index cd5c8fd4..898abb4e 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Rinominato", "renamedBody": "Il documento si chiama ora {name}.", - "renameFailed": "Impossibile rinominare in {name}" + "renameFailed": "Impossibile rinominare in {name}", + "andOthers": "e altri {count}", + "transferHeading": "File in fase di modifica", + "transferBody": "Questo file è aperto in OnlyOffice. Continuare potrebbe interferire con un salvataggio in corso. Vuoi continuare?", + "transferCancel": "Annulla", + "transferConfirm": "Continua", + "editingBy": "In modifica in ONLYOFFICE da {names}", + "editingNow": "In modifica in ONLYOFFICE" } } diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index cf169e4a..1370dead 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "이름 변경됨", "renamedBody": "문서 이름이 {name}(으)로 바뀌었습니다.", - "renameFailed": "{name}(으)로 이름을 바꾸지 못했습니다" + "renameFailed": "{name}(으)로 이름을 바꾸지 못했습니다", + "andOthers": "외 {count}개", + "transferHeading": "파일 편집 중", + "transferBody": "이 파일은 OnlyOffice에서 열려 있습니다. 계속하면 진행 중인 저장 작업에 문제가 생길 수 있습니다. 계속하시겠습니까?", + "transferCancel": "취소", + "transferConfirm": "계속", + "editingBy": "ONLYOFFICE에서 편집 중: {names}", + "editingNow": "ONLYOFFICE에서 편집 중" } } diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 50da957a..64e270dc 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Hernoemd", "renamedBody": "Het document heet nu {name}.", - "renameFailed": "Hernoemen naar {name} is mislukt" + "renameFailed": "Hernoemen naar {name} is mislukt", + "andOthers": "en {count} andere", + "transferHeading": "Bestand wordt bewerkt", + "transferBody": "Dit bestand is geopend in OnlyOffice. Doorgaan kan een lopende opslag verstoren. Wilt u doorgaan?", + "transferCancel": "Annuleren", + "transferConfirm": "Doorgaan", + "editingBy": "Wordt bewerkt in ONLYOFFICE door {names}", + "editingNow": "Wordt bewerkt in ONLYOFFICE" } } diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index 547bc6f8..6fb8aa6f 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Zmieniono nazwę", "renamedBody": "Dokument nazywa się teraz {name}.", - "renameFailed": "Nie można zmienić nazwy na {name}" + "renameFailed": "Nie można zmienić nazwy na {name}", + "andOthers": "i {count} więcej", + "transferHeading": "Plik jest edytowany", + "transferBody": "Ten plik jest otwarty w OnlyOffice. Kontynuowanie może zakłócić trwający zapis. Czy chcesz kontynuować?", + "transferCancel": "Anuluj", + "transferConfirm": "Kontynuuj", + "editingBy": "Edytowane w ONLYOFFICE przez: {names}", + "editingNow": "Edytowane w ONLYOFFICE" } } diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index d4f3fff8..0f3b4d8f 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Renomeado", "renamedBody": "O documento agora se chama {name}.", - "renameFailed": "Não foi possível renomear para {name}" + "renameFailed": "Não foi possível renomear para {name}", + "andOthers": "e mais {count}", + "transferHeading": "O arquivo está sendo editado", + "transferBody": "Este arquivo está aberto no OnlyOffice. Continuar pode atrapalhar um salvamento em andamento. Deseja continuar?", + "transferCancel": "Cancelar", + "transferConfirm": "Continuar", + "editingBy": "Sendo editado no ONLYOFFICE por {names}", + "editingNow": "Sendo editado no ONLYOFFICE" } } diff --git a/frontend/src/i18n/locales/ro.json b/frontend/src/i18n/locales/ro.json index 5e4c9347..f4d7ccfd 100644 --- a/frontend/src/i18n/locales/ro.json +++ b/frontend/src/i18n/locales/ro.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Redenumit", "renamedBody": "Documentul se numește acum {name}.", - "renameFailed": "Nu s-a putut redenumi în {name}" + "renameFailed": "Nu s-a putut redenumi în {name}", + "andOthers": "și încă {count}", + "transferHeading": "Fișierul este în curs de editare", + "transferBody": "Acest fișier este deschis în OnlyOffice. Continuarea poate perturba o salvare în curs. Vrei să continui?", + "transferCancel": "Anulează", + "transferConfirm": "Continuă", + "editingBy": "Se editează în ONLYOFFICE de către {names}", + "editingNow": "Se editează în ONLYOFFICE" } } diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 1f7f89fb..c06cba1d 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Переименовано", "renamedBody": "Документ теперь называется {name}.", - "renameFailed": "Не удалось переименовать в {name}" + "renameFailed": "Не удалось переименовать в {name}", + "andOthers": "и еще {count}", + "transferHeading": "Файл редактируется", + "transferBody": "Этот файл открыт в OnlyOffice. Продолжение может нарушить текущее сохранение. Хотите продолжить?", + "transferCancel": "Отмена", + "transferConfirm": "Продолжить", + "editingBy": "Редактируется в ONLYOFFICE: {names}", + "editingNow": "Редактируется в ONLYOFFICE" } } diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index 58a56503..a76b8871 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Namnet ändrat", "renamedBody": "Dokumentet heter nu {name}.", - "renameFailed": "Det gick inte att byta namn till {name}" + "renameFailed": "Det gick inte att byta namn till {name}", + "andOthers": "och {count} till", + "transferHeading": "Filen redigeras", + "transferBody": "Den här filen är öppen i OnlyOffice. Om du fortsätter kan en pågående sparning störas. Vill du fortsätta?", + "transferCancel": "Avbryt", + "transferConfirm": "Fortsätt", + "editingBy": "Redigeras i ONLYOFFICE av {names}", + "editingNow": "Redigeras i ONLYOFFICE" } } diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 304f9ce2..aa614e8b 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "已重命名", "renamedBody": "文档现在名为 {name}。", - "renameFailed": "无法重命名为 {name}" + "renameFailed": "无法重命名为 {name}", + "andOthers": "以及其他 {count} 项", + "transferHeading": "文件正在编辑中", + "transferBody": "此文件已在 OnlyOffice 中打开。继续可能会干扰正在进行的保存。是否继续?", + "transferCancel": "取消", + "transferConfirm": "继续", + "editingBy": "正在 ONLYOFFICE 中编辑:{names}", + "editingNow": "正在 ONLYOFFICE 中编辑" } } diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 0faee896..3b0b94d0 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "已重新命名", "renamedBody": "文件現在名為 {name}。", - "renameFailed": "無法重新命名為 {name}" + "renameFailed": "無法重新命名為 {name}", + "andOthers": "以及其他 {count} 項", + "transferHeading": "檔案正在編輯中", + "transferBody": "此檔案已在 OnlyOffice 中開啟。繼續可能會干擾正在進行的儲存。是否繼續?", + "transferCancel": "取消", + "transferConfirm": "繼續", + "editingBy": "正在 ONLYOFFICE 中編輯:{names}", + "editingNow": "正在 ONLYOFFICE 中編輯" } } diff --git a/frontend/src/layouts/BrowserLayout.vue b/frontend/src/layouts/BrowserLayout.vue index fc3a0cc1..e7ffab01 100644 --- a/frontend/src/layouts/BrowserLayout.vue +++ b/frontend/src/layouts/BrowserLayout.vue @@ -22,6 +22,7 @@ import { useAppSettings } from '@/stores/appSettings'; import { useFeaturesStore } from '@/stores/features'; import InfoPanel from '@/components/InfoPanel.vue'; import VersionsPanel from '@/components/VersionsPanel.vue'; +import OnlyOfficeTransferConfirm from '@/components/OnlyOfficeTransferConfirm.vue'; import { useFileUploader } from '@/composables/fileUploader'; import { useKeyboardShortcuts } from '@/composables/keyboardShortcuts'; import SpotlightSearch from '@/components/SpotlightSearch.vue'; @@ -227,6 +228,7 @@ const handleGuestLogin = () => { + diff --git a/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue b/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue index d4f33527..3272507c 100644 --- a/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue +++ b/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue @@ -33,6 +33,7 @@ import { fetchOnlyOfficeConfig, heartbeatOnlyOfficeSession, renameOnlyOfficeDocument, + requestOnlyOfficeForceSave, } from '@/api'; import { useFileStore } from '@/stores/fileStore'; import { useNotificationsStore } from '@/stores/notifications'; @@ -59,6 +60,9 @@ const error = ref(null); const documentPath = ref(''); const sessionId = ref(null); let heartbeatTimer = null; +let autoSaveTimer = null; +let autoSaveIntervalMs = 0; +let hasUnsavedChanges = false; let disposed = false; /** @@ -83,9 +87,42 @@ const stopHeartbeat = () => { heartbeatTimer = null; }; +/** + * Write what the editor is holding, without waiting for it to decide. + * + * The Document Server writes the document through its own callback, which it + * sends when it considers the document finished with — seconds after the last + * keystroke, and long after the folder behind the editor has been listed again + * with the old content. Asking for it explicitly is what makes an edit visible + * where it was made. + */ +const forceSave = (reason) => { + if (!documentPath.value || !sessionId.value) return Promise.resolve({ queued: false }); + return requestOnlyOfficeForceSave(documentPath.value, { + sessionId: sessionId.value, + reason, + }).catch(() => ({ queued: false })); +}; + +const startAutoSave = () => { + if (autoSaveTimer) clearInterval(autoSaveTimer); + if (disposed || !autoSaveIntervalMs || !sessionId.value) return; + autoSaveTimer = setInterval(() => { + // Only when there is something to write: the editor tells us, and asking + // for a save of an unchanged document costs a full conversion. + if (hasUnsavedChanges) void forceSave('auto'); + }, autoSaveIntervalMs); +}; + +const stopAutoSave = () => { + if (autoSaveTimer) clearInterval(autoSaveTimer); + autoSaveTimer = null; +}; + /** End the session, so the document stops being reported as open by whoever left. */ const endSession = ({ beacon = false } = {}) => { stopHeartbeat(); + stopAutoSave(); const current = sessionId.value; if (!current || !documentPath.value) return; sessionId.value = null; @@ -159,8 +196,11 @@ const load = async () => { documentServerUrl, config: cfg, editorSessionId, + autoSaveIntervalMs: configuredAutoSaveIntervalMs, } = await fetchOnlyOfficeConfig(path, 'edit'); sessionId.value = editorSessionId || null; + autoSaveIntervalMs = Number(configuredAutoSaveIntervalMs) || 0; + hasUnsavedChanges = false; serverUrl.value = documentServerUrl; logger.debug('ONLYOFFICE config', cfg); cfg.events = { @@ -168,6 +208,12 @@ const load = async () => { onDocumentReady() { logger.debug('ONLYOFFICE document ready', { path: documentPath.value }); startHeartbeat(); + startAutoSave(); + }, + // The editor says whether it is holding anything unwritten, which is what + // decides whether a periodic save is worth a document conversion. + onDocumentStateChange(event) { + if (typeof event?.data === 'boolean') hasUnsavedChanges = event.data; }, onRequestRename(event) { void renameDocument(event?.data); diff --git a/frontend/src/stores/fileStore.js b/frontend/src/stores/fileStore.js index 66ba1c84..bd167957 100644 --- a/frontend/src/stores/fileStore.js +++ b/frontend/src/stores/fileStore.js @@ -433,6 +433,10 @@ export const useFileStore = defineStore('fileStore', () => { currentPath.value = normalizePath(path); } + // Fields this store puts on an item itself, which no listing ever sends and + // which must survive a refresh. + const LOCAL_ONLY_ITEM_FIELDS = new Set(['thumbnail']); + async function fetchPathItems(path) { const previousItems = Array.isArray(currentPathItems.value) ? currentPathItems.value : []; @@ -478,6 +482,15 @@ export const useFileStore = defineStore('fileStore', () => { // Preserve any locally-added thumbnail if the backend // does not send one, but refresh all other metadata. const prevThumbnail = existing.thumbnail; + // A field the listing has stopped sending has to go, not merely be + // overwritten: `Object.assign` cannot remove anything, so a mark the + // row no longer carries — a document nobody has open any more, a + // history that was just emptied — stayed for as long as the folder + // was on screen. `supportsThumbnail` was patched for exactly this, + // one field at a time; this is the same rule for all of them. + for (const key of Object.keys(existing)) { + if (!(key in incoming) && !LOCAL_ONLY_ITEM_FIELDS.has(key)) delete existing[key]; + } Object.assign(existing, incoming); if (!incoming.thumbnail && prevThumbnail) { existing.thumbnail = prevThumbnail; diff --git a/frontend/src/views/FolderView.vue b/frontend/src/views/FolderView.vue index 23980b74..3232ef81 100644 --- a/frontend/src/views/FolderView.vue +++ b/frontend/src/views/FolderView.vue @@ -7,6 +7,7 @@ import FileObject from '@/components/FileObject.vue'; import { useFileStore } from '@/stores/fileStore'; import { useFolderSizeStore } from '@/stores/folderSize'; import { useFeaturesStore } from '@/stores/features'; +import { useOnlyOfficeActivity } from '@/composables/useOnlyOfficeActivity'; import LoadingIcon from '@/icons/LoadingIcon.vue'; import { useSelection } from '@/composables/itemSelection'; import { useExplorerContextMenu } from '@/composables/contextMenu'; @@ -261,7 +262,17 @@ const refreshFolderSizes = () => { watch(() => fileStore.getCurrentPathItems, refreshFolderSizes, { immediate: true }); -onMounted(loadFiles); +// Whoever has a document open in an editor, kept current while this folder is +// on screen: the server holds the request until somebody joins or leaves. +const onlyofficeActivity = useOnlyOfficeActivity({ + featuresStore, + refresh: () => fileStore.fetchPathItems(fileStore.currentPath).catch(() => {}), +}); + +onMounted(() => { + void loadFiles(); + void onlyofficeActivity.start(); +}); watch(hasMoreItems, () => { setupLoadMoreObserver(); @@ -394,6 +405,10 @@ useEventListener(window, 'pointercancel', stopResize); useEventListener(window, 'resize', updateScrollState); useEventListener(window, 'scroll', updateScrollState, { passive: true }); +onBeforeUnmount(() => { + onlyofficeActivity.stop(); +}); + onBeforeUnmount(() => { stopResize(); disconnectLoadMoreObserver();