Cleanup: i18n key macro, blocking helper, typed CodeMirror bindings

A whole-repo over-engineering audit, applied. 647 lines and one
dependency removed. Behaviour is unchanged except for the admin root
validation noted below.

i18n: the `k` module declared 280 constants whose values were the EN
table's keys, spelled twice. An `i18n_keys!` macro now emits the constant
and the EN row from one line. That also removes
all_k_constants_exist_in_en, a test that parsed this file's own source to
reconcile the two lists. The macro makes the mismatch it hunted
unrepresentable. The k module's topical grouping comments are gone,
because EN must stay sorted for the binary search in `find`.

api: fourteen call sites repeated the same eight-line block mapping a
spawn_blocking JoinError to err_internal. common::blocking does it once.
Five spawn_blocking calls stay. stream_file and stream_archive are never
awaited. The upload parent check maps its io::Error to 403. The final
upload rename inspects the error itself.

validate_roots had one `?` where it needed two. It unwrapped the
JoinError and dropped the inner Result, so a root path that does not
resolve was stored without complaint. It now returns 400. The message is
built inside the closure, so it still carries the FsError.

cm.rs drove CodeMirror through js_sys::Reflect::get/apply/set.
wasm-bindgen already does this. The bindings are an extern block with
js_namespace = __fbng_cm, and wrapper.js takes positional arguments
instead of an options object. Its dead `version: 1` field went with it.

Also:
- grid_view and list_view were the same eleven-argument function. They
  differed in two class names and the item body. One entries_view takes
  the ViewMode.
- list_root duplicated file_get arm for arm. It delegates now, as
  file_put_root and dispatch_root already did.
- api/auth.rs::require_user was a copy of api/common.rs::session_auth.
  update_profile takes the SessionUser extractor instead.
- parse_location's four manage-view arms differed only in the Section.
  Section::from_segment inverts the existing segment().
- select_value was defined identically in views/admin.rs and
  views/shares.rs. It moved to util.rs.
- The `ok: bool` field on OkResp, DeleteResp, UploadResp and SaveResp was
  never read. The status code already says it.
- globset was declared and never imported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commitb323697057b46bbdc5d63dc4bc0ec92b7265981c
Parent496c1c5
17 files changed, 530 insertions(+), 1178 deletions(-)
▾MCargo.lock
@@ -2248,7 +2248,6 @@ dependencies = [
"clap",
"flate2",
"futures-util",
"globset",
"grep",
"http-body-util",
"ignore",
▾Mapi-types/src/lib.rs
@@ -341,31 +341,27 @@ pub struct AdminUser {
pub roots: Vec<RootInfo>,
}
/// Acknowledges a successful mutation: `{"ok": true}`.
/// Acknowledges a successful mutation. Carries nothing: the 2xx status is
/// the acknowledgement, so the body is the empty object.
#[derive(Serialize, Deserialize)]
pub struct OkResp {
pub ok: bool,
}
pub struct OkResp {}
/// DELETE `{FILES}/...`: whether the removed item was a folder (the client
/// reports "folder deleted" vs "file deleted").
#[derive(Serialize, Deserialize)]
pub struct DeleteResp {
pub ok: bool,
pub is_dir: bool,
}
/// Upload success: `{"ok": true, "uploaded": n}`.
/// Upload success: how many files were written.
#[derive(Serialize, Deserialize)]
pub struct UploadResp {
pub ok: bool,
pub uploaded: usize,
}
/// PUT `?action=content` (editor save): the file's new mtime (unix seconds).
#[derive(Serialize, Deserialize)]
pub struct SaveResp {
pub ok: bool,
pub mtime: i64,
}
▾Mserver/Cargo.toml
@@ -44,7 +44,6 @@ tokio-stream = { version = "0.1", features = ["sync"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
ignore = "0.4"
globset = "0.4"
grep = "0.4"
[dev-dependencies]
▾Mserver/src/api/admin.rs
@@ -9,7 +9,9 @@ use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
use crate::api::common::AdminUser as AdminGuard;
use crate::api::common::{display_name, hash_password, validate_account_name, validate_password};
use crate::api::common::{
blocking, display_name, hash_password, validate_account_name, validate_password,
};
use crate::db::Db;
use crate::error::{ApiError, AppState};
use crate::fs;
@@ -56,19 +58,15 @@ async fn validate_roots(state: &AppState, roots: &[Root]) -> Result<Vec<(String,
r.path.trim().to_string()
};
let server_root = state.root.clone();
let path2 = path.clone();
tokio::task::spawn_blocking(move || fs::resolve_root(&server_root, &path2))
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})?
.map_err(|e| {
ApiError::new(StatusCode::BAD_REQUEST, format!("root path '{path}': {e}"))
})?;
let (path2, label) = (path.clone(), path.clone());
// The message is built inside the closure so it carries the
// `FsError`, not the join failure.
blocking(move || {
fs::resolve_root(&server_root, &path2).map_err(|e| {
ApiError::new(StatusCode::BAD_REQUEST, format!("root path '{label}': {e}"))
})
})
.await?;
out.push((path, r.mode));
}
Ok(out)
@@ -236,7 +234,7 @@ pub async fn delete_user(
"err_user_not_found",
));
}
Ok(Json(OkResp { ok: true }))
Ok(Json(OkResp {}))
}
/// GET /api/admin/settings
▾Mserver/src/api/auth.rs
@@ -7,7 +7,10 @@ use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use crate::api::common::{display_name, hash_password, validate_account_name, validate_password};
use crate::api::common::{
SessionUser, display_name, hash_password, session_auth, validate_account_name,
validate_password,
};
use crate::auth::{self, clear_session_cookie, parse_session_cookie, session_cookie};
use crate::db::{RootRow, User};
use crate::error::{ApiError, AppState};
@@ -30,35 +33,10 @@ pub async fn me(
}));
}
let (user, roots) = require_user(&state, &headers).await?;
let (user, roots) = session_auth(&headers, &state).await?;
Ok(Json(me_for(&state, &user, roots).await?))
}
/// The signed-in user behind the session cookie plus their roots, or 401.
async fn require_user(
state: &AppState,
headers: &HeaderMap,
) -> Result<(User, Vec<RootRow>), ApiError> {
let Some(token) = parse_session_cookie(headers) else {
return Err(ApiError::localized(
StatusCode::UNAUTHORIZED,
"not signed in",
"err_not_signed_in",
));
};
state
.db
.session_user_with_roots(&token)
.await?
.ok_or_else(|| {
ApiError::localized(
StatusCode::UNAUTHORIZED,
"session expired, please sign in again",
"err_session_expired",
)
})
}
/// Build the `/api/auth/me` payload for an authenticated user.
async fn me_for(state: &AppState, user: &User, roots: Vec<RootRow>) -> Result<Me, ApiError> {
let roots: Vec<RootInfo> = roots
@@ -123,10 +101,9 @@ fn valid_language(tag: &str) -> bool {
pub async fn update_profile(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
SessionUser { mut user, roots }: SessionUser,
Json(body): Json<ProfilePatch>,
) -> Result<Json<Me>, ApiError> {
let (mut user, roots) = require_user(&state, &headers).await?;
if let Some(Some(ref tag)) = body.language
&& !valid_language(tag)
{
@@ -180,7 +157,7 @@ pub async fn setup(
let token = auth::random_token();
state.db.create_session(user.id, &token).await?;
let mut res = Json(OkResp { ok: true }).into_response();
let mut res = Json(OkResp {}).into_response();
res.headers_mut().insert(
header::SET_COOKIE,
session_cookie(&token, state.https).parse().unwrap(),
@@ -211,7 +188,7 @@ pub async fn login(
let token = auth::random_token();
state.db.create_session(user.id, &token).await?;
let mut res = Json(OkResp { ok: true }).into_response();
let mut res = Json(OkResp {}).into_response();
res.headers_mut().insert(
header::SET_COOKIE,
session_cookie(&token, state.https).parse().unwrap(),
@@ -224,7 +201,7 @@ pub async fn logout(State(state): State<Arc<AppState>>, headers: HeaderMap) -> R
if let Some(token) = parse_session_cookie(&headers) {
let _ = state.db.delete_session(&token).await;
}
let mut res = Json(OkResp { ok: true }).into_response();
let mut res = Json(OkResp {}).into_response();
res.headers_mut().insert(
header::SET_COOKIE,
clear_session_cookie(state.https).parse().unwrap(),
▾Mserver/src/api/common.rs
@@ -66,7 +66,7 @@ where
// 2. Signed-in session. Admins also get the whole server root,
// read-only, under `ADMIN_ROOT` (the folder picker in user
// management browses it).
let (user, mut roots) = session_auth(parts, state).await?;
let (user, mut roots) = session_auth(&parts.headers, state).await?;
if user.is_admin {
roots.push(RootRow {
id: api_types::ADMIN_ROOT,
@@ -105,14 +105,17 @@ where
"err_share_token_forbidden",
));
}
let (user, roots) = session_auth(parts, state.state()).await?;
let (user, roots) = session_auth(&parts.headers, state.state()).await?;
Ok(SessionUser { user, roots })
}
}
/// Authenticate via the session cookie only, returning the user and roots.
async fn session_auth(parts: &Parts, state: &AppState) -> Result<(User, Vec<RootRow>), ApiError> {
let Some(token) = parse_session_cookie(&parts.headers) else {
pub(crate) async fn session_auth(
headers: &axum::http::HeaderMap,
state: &AppState,
) -> Result<(User, Vec<RootRow>), ApiError> {
let Some(token) = parse_session_cookie(headers) else {
return Err(ApiError::localized(
StatusCode::UNAUTHORIZED,
"not signed in",
@@ -200,6 +203,32 @@ pub(crate) fn validate_account_name(name: &str) -> Result<(), ApiError> {
Ok(())
}
/// Run blocking work (filesystem, mostly) on the blocking pool.
///
/// The join itself can only fail if the task panicked or the runtime is
/// shutting down; both are `500`. Every caller used to spell that out, so
/// the `?` on the outer result is the join and the inner one is the work.
pub(crate) async fn blocking<T, E>(
f: impl FnOnce() -> Result<T, E> + Send + 'static,
) -> Result<T, ApiError>
where
T: Send + 'static,
E: Into<ApiError> + Send + 'static,
{
tokio::task::spawn_blocking(f)
.await
.map_err(|_| internal_error())?
.map_err(Into::into)
}
pub(crate) fn internal_error() -> ApiError {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
}
/// Hash a password off the async executor. Argon2 is slow by design, so
/// running it inline would block a tokio worker thread for the whole cost.
pub(crate) async fn hash_password(pw: &str) -> Result<String, ApiError> {
▾Mserver/src/api/files.rs
@@ -24,7 +24,7 @@ use tokio::io::AsyncWriteExt;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use crate::api::common::{AuthUser, target_rel};
use crate::api::common::{AuthUser, blocking, target_rel};
use crate::archive::{self, ArchiveFormat};
use crate::db::{RootRow, ShareRow};
use crate::error::{ApiError, AppState};
@@ -105,47 +105,23 @@ pub async fn file_get(
/// GET /api/files/{root_id} — list the root directory itself, or serve the
/// root item via `?action=download|preview|content` (the root of a *file*
/// share is the file itself).
///
/// Same thing as [`file_get`] with an empty path, so it delegates there.
pub async fn list_root(
State(state): State<Arc<AppState>>,
state: State<Arc<AppState>>,
auth: AuthUser,
path: AxumPath<i64>,
query: AxumQuery<FileQuery>,
headers: axum::http::HeaderMap,
) -> Result<Response, ApiError> {
let root_id = path.0;
match query.action.as_deref() {
Some(a) if a == api_types::ACTION_DOWNLOAD => {
let range = range_header(&headers);
download(
state,
auth,
root_id,
String::new(),
query.format.as_deref(),
range,
ims_header(&headers),
)
.await
}
Some(a) if a == api_types::ACTION_PREVIEW => {
preview(
state,
auth,
root_id,
String::new(),
range_header(&headers),
ims_header(&headers),
)
.await
}
Some(a) if a == api_types::ACTION_CONTENT => {
content(state, auth, root_id, String::new()).await
}
_ => {
let json = list_inner(state, auth, root_id, String::new()).await?;
Ok(json.into_response())
}
}
file_get(
state,
auth,
AxumPath((path.0, String::new())),
query,
headers,
)
.await
}
fn range_header(headers: &axum::http::HeaderMap) -> Option<String> {
@@ -194,18 +170,11 @@ async fn list_inner(
let server_root = state.root.clone();
let root_rel = root.path.clone();
// Resolve and list in one blocking hop: both are filesystem work.
let (entries, truncated) = tokio::task::spawn_blocking(move || {
let (entries, truncated) = blocking(move || {
let full = fs::resolve_path(&server_root, &root_rel, &req_rel)?;
fs::list_dir(&full)
})
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
.await?;
Ok(Json(FilesResp { entries, truncated }))
}
@@ -249,7 +218,7 @@ async fn resolve_item(
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel.to_string());
// A file share's synthetic root *is* the file, so resolve it directly.
let share_target = share.filter(|s| s.is_file).map(|s| s.target.clone());
let inner = tokio::task::spawn_blocking(move || {
blocking(move || {
let full = match share_target {
Some(target) => fs::resolve_file(&server_root, &target)?,
None => fs::resolve_path(&server_root, &root_rel, &rel)?,
@@ -263,14 +232,6 @@ async fn resolve_item(
Ok::<_, FsError>((full, name, meta.is_dir(), meta.len(), mtime))
})
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})?;
Ok(inner?)
}
/// `GET ...?action=download` — a single file as-is, a folder as an archive
@@ -381,15 +342,14 @@ async fn content(
}
// mtime and bytes from one handle, mtime first: a write in between would
// otherwise hand the editor a stale conflict anchor for fresh content.
let (mtime, bytes) = tokio::task::spawn_blocking(move || -> io::Result<(i64, Vec<u8>)> {
let (mtime, bytes) = blocking(move || -> io::Result<(i64, Vec<u8>)> {
let mut f = std::fs::File::open(&full)?;
let mtime = fs::mtime_secs(&f.metadata()?).unwrap_or(0);
let mut bytes = Vec::with_capacity(size as usize);
f.read_to_end(&mut bytes)?;
Ok((mtime, bytes))
})
.await
.map_err(|_| io::Error::other("join"))??;
.await?;
Ok((
[
(
@@ -474,19 +434,12 @@ async fn put_inner(
.map(|s| s.target.clone());
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
let content = body.to_vec();
let mtime = tokio::task::spawn_blocking(move || match share_target {
let mtime = blocking(move || match share_target {
Some(target) => fs::save_file_at(&server_root, &target, &content, expected),
None => fs::save_file(&server_root, &root_rel, &rel, &content, expected),
})
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
Ok(Json(SaveResp { ok: true, mtime }))
.await?;
Ok(Json(SaveResp { mtime }))
}
/// Stream a single file to the client with the right disposition.
@@ -796,16 +749,8 @@ async fn create_file(
));
}
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
tokio::task::spawn_blocking(move || fs::create_file(&server_root, &root_rel, &rel))
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
Ok(Json(OkResp { ok: true }))
blocking(move || fs::create_file(&server_root, &root_rel, &rel)).await?;
Ok(Json(OkResp {}))
}
// ---------------------------------------------------------------------------
@@ -827,16 +772,8 @@ async fn mkdir(
));
}
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
tokio::task::spawn_blocking(move || fs::mkdir(&server_root, &root_rel, &rel))
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
Ok(Json(OkResp { ok: true }))
blocking(move || fs::mkdir(&server_root, &root_rel, &rel)).await?;
Ok(Json(OkResp {}))
}
// ---------------------------------------------------------------------------
@@ -870,19 +807,12 @@ async fn mutation(
req_rel,
body.overwrite,
);
let vacated = tokio::task::spawn_blocking(move || {
let vacated = blocking(move || {
fs::rename_item(&server_root, &root_rel, &rel, &new_name, overwrite)
})
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
.await?;
revoke_shares_at(&state, &vacated).await;
Ok(Json(OkResp { ok: true }))
Ok(Json(OkResp {}))
}
Op::Move | Op::Copy => {
let dst_root_id = body.dst_root_id.ok_or_else(|| {
@@ -910,7 +840,7 @@ async fn mutation(
req_rel,
body.overwrite,
);
let vacated = tokio::task::spawn_blocking(move || {
let vacated = blocking(move || {
if op_is_move {
fs::move_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
.map(Some)
@@ -920,18 +850,11 @@ async fn mutation(
.map(|()| None)
}
})
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
.await?;
if let Some(vacated) = vacated {
revoke_shares_at(&state, &vacated).await;
}
Ok(Json(OkResp { ok: true }))
Ok(Json(OkResp {}))
}
}
}
@@ -955,18 +878,9 @@ pub async fn delete(
));
}
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
let (is_dir, gone) =
tokio::task::spawn_blocking(move || fs::remove_item(&server_root, &root_rel, &rel))
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
let (is_dir, gone) = blocking(move || fs::remove_item(&server_root, &root_rel, &rel)).await?;
revoke_shares_at(&state, &gone).await;
Ok(Json(DeleteResp { ok: true, is_dir }))
Ok(Json(DeleteResp { is_dir }))
}
// ---------------------------------------------------------------------------
@@ -985,20 +899,13 @@ async fn upload(
// containment boundary (a symlink may legitimately point elsewhere inside it).
let (root_abs, base) = {
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
tokio::task::spawn_blocking(move || {
blocking(move || {
Ok::<_, FsError>((
fs::resolve_root(&server_root, &root_rel)?,
fs::resolve_dir(&server_root, &root_rel, &rel)?,
))
})
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??
.await?
};
let boundary = req
.headers()
@@ -1171,15 +1078,7 @@ async fn upload(
}
let tmp2 = tmp.clone();
let target2 = target.clone();
let renamed = tokio::task::spawn_blocking(move || std::fs::rename(&tmp2, &target2))
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})?;
let renamed = tokio::task::spawn_blocking(move || std::fs::rename(&tmp2, &target2)).await;
if let Err(e) = renamed {
let _ = tokio::fs::remove_file(&tmp).await;
tracing::warn!(error = %e, "rename failed during upload");
@@ -1207,7 +1106,7 @@ async fn upload(
)
.with_extra(serde_json::json!({ "skipped": skipped, "uploaded": uploaded })));
}
Ok(Json(UploadResp { ok: true, uploaded }).into_response())
Ok(Json(UploadResp { uploaded }).into_response())
}
fn parse_boundary(content_type: &str) -> Option<String> {
▾Mserver/src/api/search.rs
@@ -40,7 +40,7 @@ use grep::searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkMatch
use ignore::{DirEntry, WalkBuilder, WalkState};
use serde::Deserialize;
use crate::api::common::AuthUser;
use crate::api::common::{AuthUser, blocking};
use crate::db::RootRow;
use crate::error::{ApiError, AppState};
@@ -151,15 +151,7 @@ pub(super) async fn search(
.to_string();
let (server_root, root_path, start) =
(state.root.clone(), root.path.clone(), start_rel.clone());
tokio::task::spawn_blocking(move || crate::fs::resolve_dir(&server_root, &root_path, &start))
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
blocking(move || crate::fs::resolve_dir(&server_root, &root_path, &start)).await?;
// One slot per running search, held until the walk ends. Each search owns
// a pool of walker threads, so unbounded concurrency would swamp the box.
▾Mserver/src/api/shares.rs
@@ -16,7 +16,7 @@ use axum::Json;
use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
use crate::api::common::{SessionUser, display_name, target_rel};
use crate::api::common::{SessionUser, blocking, display_name, target_rel};
use crate::auth;
use crate::db::ShareRow;
use crate::error::{ApiError, AppState};
@@ -102,15 +102,7 @@ pub async fn create(
let root_path = root.path.clone();
let req = body.path.trim().to_string();
let req = if req.is_empty() { ".".to_string() } else { req };
let abs = tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_path, &req))
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
let abs = blocking(move || fs::resolve_path(&server_root, &root_path, &req)).await?;
let target = target_rel(&state, &abs);
let is_file = abs.is_file();
@@ -145,7 +137,7 @@ pub async fn delete(
"err_share_not_found",
));
}
Ok(Json(OkResp { ok: true }))
Ok(Json(OkResp {}))
}
/// GET /api/share/{token} — public resolve for the share page.
@@ -172,14 +164,12 @@ pub async fn resolve(
// file's kind up front (it cannot list a file's "contents").
if row.is_file {
let (server_root, target) = (state.root.clone(), row.target.clone());
info.kind = tokio::task::spawn_blocking(move || {
fs::resolve_file(&server_root, &target)
.ok()
.map(|p| fs::detect_kind(&p, false))
})
.await
.ok()
.flatten();
// An unresolvable target just means no kind; the share itself is
// still returned.
info.kind = blocking(move || fs::resolve_file(&server_root, &target))
.await
.ok()
.map(|p| fs::detect_kind(&p, false));
}
Ok(Json(info))
}
▾Mweb/cm/wrapper.js
@@ -90,38 +90,36 @@ const appTheme = EditorView.theme({
"&.cm-focused": { outline: "none" },
});
// Positional arguments throughout, so the Rust side can bind these directly
// with #[wasm_bindgen(js_namespace = __fbng_cm)] instead of building an
// options object.
window.__fbng_cm = {
version: 1,
// Create an editor in `container`. Returns an opaque view handle.
// opts: { value, filename, editable, onUpdate? }
create(container, opts) {
const lang = langFromFilename(opts.filename);
create(container, value, filename, editable, onUpdate) {
const extensions = [appTheme, basicSetup];
if (isDark()) extensions.push(oneDark);
const le = langExtension(lang);
const le = langExtension(langFromFilename(filename));
if (le) extensions.push(le);
if (opts.editable) {
if (editable) {
// Ctrl/Cmd-S is intercepted by the host (see Rust keybinding).
extensions.push(EditorState.allowMultipleSelections.of(true));
} else {
extensions.push(EditorState.readOnly.of(true), EditorView.editable.of(false));
}
if (typeof opts.onUpdate === "function") {
if (typeof onUpdate === "function") {
extensions.push(
EditorView.updateListener.of((update) => {
if (update.docChanged) opts.onUpdate();
if (update.docChanged) onUpdate();
})
);
}
const view = new EditorView({
state: EditorState.create({ doc: opts.value || "", extensions }),
return new EditorView({
state: EditorState.create({ doc: value || "", extensions }),
parent: container,
});
return view;
},
destroy(view) {
▾Mweb/src/cm.rs
@@ -4,8 +4,37 @@
//! `window.__fbng_cm` global. This module is the *only* place that knows how
//! to talk to it, so swapping the editor later means re-implementing just
//! these functions.
//!
//! The bundle is a separate `<script>`, so it may be missing (a failed load,
//! a stale cache). Every binding is therefore `catch`: a missing global
//! surfaces as an `Err` instead of a wasm trap.
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = __fbng_cm, js_name = create, catch)]
fn js_create(
container: &web_sys::Element,
value: &str,
filename: &str,
editable: bool,
on_update: Option<js_sys::Function>,
) -> Result<JsValue, JsValue>;
#[wasm_bindgen(js_namespace = __fbng_cm, js_name = destroy, catch)]
fn js_destroy(view: &JsValue) -> Result<(), JsValue>;
#[wasm_bindgen(js_namespace = __fbng_cm, js_name = setValue, catch)]
fn js_set_value(view: &JsValue, text: &str) -> Result<(), JsValue>;
#[wasm_bindgen(js_namespace = __fbng_cm, js_name = getValue, catch)]
fn js_get_value(view: &JsValue) -> Result<JsValue, JsValue>;
use wasm_bindgen::{JsCast, JsValue};
#[wasm_bindgen(js_namespace = __fbng_cm, js_name = focus, catch)]
fn js_focus(view: &JsValue) -> Result<(), JsValue>;
}
/// A live CodeMirror editor. Destroy it with [`destroy`] when the host goes
/// away (we do that in a Leptos `on_cleanup`).
@@ -15,34 +44,12 @@ pub struct Cm {
/// Extract a human-readable message from a JS error value.
fn err_str(e: JsValue) -> String {
e.clone()
.dyn_into::<js_sys::Error>()
e.dyn_into::<js_sys::Error>()
.ok()
.and_then(|e| e.message().as_string())
.unwrap_or_else(|| "editor error".to_string())
}
fn global() -> Result<JsValue, String> {
let w = web_sys::window().ok_or("no window".to_string())?;
let wjs: JsValue = w.into();
let v = js_sys::Reflect::get(&wjs, &JsValue::from_str("__fbng_cm")).map_err(err_str)?;
if v.is_null() || v.is_undefined() {
return Err("cm6 bundle not loaded".to_string());
}
Ok(v)
}
/// Call `method` on `g` with the given args.
fn call(g: &JsValue, method: &str, args: &[&JsValue]) -> Result<JsValue, String> {
let m = js_sys::Reflect::get(g, &JsValue::from_str(method)).map_err(err_str)?;
let f: js_sys::Function = m.unchecked_into();
let arr = js_sys::Array::new();
for a in args {
arr.push(a);
}
js_sys::Reflect::apply(&f, g, &arr).map_err(err_str)
}
/// Create an editor inside `container`.
///
/// `on_update` is an optional JS function called whenever the document changes
@@ -55,59 +62,27 @@ pub fn create(
editable: bool,
on_update: Option<&JsValue>,
) -> Result<Cm, String> {
let g = global()?;
let opts = js_sys::Object::new();
js_sys::Reflect::set(
&opts,
&JsValue::from_str("value"),
&JsValue::from_str(value),
)
.map_err(err_str)?;
js_sys::Reflect::set(
&opts,
&JsValue::from_str("filename"),
&JsValue::from_str(filename),
)
.map_err(err_str)?;
js_sys::Reflect::set(
&opts,
&JsValue::from_str("editable"),
&JsValue::from_bool(editable),
)
.map_err(err_str)?;
if let Some(f) = on_update {
js_sys::Reflect::set(&opts, &JsValue::from_str("onUpdate"), f).map_err(err_str)?;
}
let view = call(
&g,
"create",
&[&JsValue::from(container), &JsValue::from(&opts)],
)?;
Ok(Cm { view })
let on_update = on_update.map(|f| f.clone().unchecked_into::<js_sys::Function>());
js_create(container, value, filename, editable, on_update)
.map(|view| Cm { view })
.map_err(err_str)
}
pub fn destroy(view: &JsValue) {
let Ok(g) = global() else { return };
let _ = call(&g, "destroy", &[view]);
let _ = js_destroy(view);
}
/// Replace the whole document (used when (re)loading a file). Used from the
/// editor (milestone 5).
/// Replace the whole document (used when (re)loading a file).
pub fn set_value(view: &JsValue, text: &str) {
let Ok(g) = global() else { return };
let _ = call(&g, "setValue", &[view, &JsValue::from_str(text)]);
let _ = js_set_value(view, text);
}
/// Read the current document text (used by the editor's save path, milestone
/// 5).
/// Read the current document text (used by the editor's save path).
pub fn get_value(view: &JsValue) -> Option<String> {
let g = global().ok()?;
call(&g, "getValue", &[view]).ok()?.as_string()
js_get_value(view).ok()?.as_string()
}
/// Focus the editor (so the caret is ready for typing on open).
pub fn focus(view: &JsValue) {
let Ok(g) = global() else { return };
let _ = call(&g, "focus", &[view]);
let _ = js_focus(view);
}
▾Mweb/src/i18n.rs
@@ -162,703 +162,308 @@ fn find(table: &'static [(&'static str, &'static str)], key: &str) -> Option<&'s
.map(|i| table[i].1)
}
/// Translation keys. English text is the value in `EN` below.
pub mod k {
// Empty states
pub const NO_FOLDERS_TITLE: &str = "no_folders_title";
pub const NO_FOLDERS_MSG: &str = "no_folders_msg";
pub const SELECT_FOLDER_TITLE: &str = "select_folder_title";
pub const SELECT_FOLDER_MSG: &str = "select_folder_msg";
pub const FOLDER_GONE_TITLE: &str = "folder_gone_title";
pub const FOLDER_GONE_MSG: &str = "folder_gone_msg";
pub const LOADING: &str = "loading";
pub const LOADING_SHARE: &str = "loading_share";
pub const RETRY: &str = "retry";
pub const EMPTY_FOLDER: &str = "empty_folder";
// Browser chrome
pub const REFRESH: &str = "refresh";
pub const GRID_VIEW: &str = "grid_view";
pub const LIST_VIEW: &str = "list_view";
pub const LIST_TRUNCATED: &str = "list_truncated";
// Entry actions
pub const OPEN: &str = "open";
pub const OPEN_IN_NEW_TAB: &str = "open_in_new_tab";
pub const DOWNLOAD: &str = "download";
pub const RENAME: &str = "rename";
pub const MOVE: &str = "move";
pub const COPY: &str = "copy";
pub const DONE: &str = "done";
pub const SHARE: &str = "share";
pub const INFO: &str = "info";
pub const DELETE: &str = "delete";
pub const NEW_FOLDER: &str = "new_folder";
pub const NEW_FILE: &str = "new_file";
pub const UPLOAD_FILES: &str = "upload_files";
pub const UPLOAD_FOLDER: &str = "upload_folder";
pub const READ_ONLY_HINT: &str = "read_only_hint";
pub const READ_ONLY_BADGE: &str = "read_only_badge";
pub const MORE_ACTIONS: &str = "more_actions";
// Dialogs: prompt / confirm
pub const FOLDER_NAME: &str = "folder_name";
pub const FILE_NAME: &str = "file_name";
pub const CREATE: &str = "create";
pub const RENAME_TITLE: &str = "rename_title";
pub const NEW_NAME: &str = "new_name";
pub const DELETE_FOLDER_MSG: &str = "delete_folder_msg";
pub const DELETE_MSG: &str = "delete_msg";
pub const DELETE_N_MSG: &str = "delete_n_msg";
pub const CANCEL: &str = "cancel";
// Dialogs: conflict / picker / download format / info
pub const OVERWRITE_QUESTION: &str = "overwrite_question";
pub const OVERWRITE: &str = "overwrite";
pub const AND_MORE: &str = "and_more";
pub const NO_SUBFOLDERS: &str = "no_subfolders";
pub const MOVE_TO: &str = "move_to";
pub const COPY_TO: &str = "copy_to";
pub const MOVE_N_TO: &str = "move_n_to";
pub const COPY_N_TO: &str = "copy_n_to";
pub const MOVE_HERE: &str = "move_here";
pub const COPY_HERE: &str = "copy_here";
pub const DOWNLOAD_TITLE: &str = "download_title";
pub const DOWNLOAD_FORMATS_MSG: &str = "download_formats_msg";
pub const FMT_ZIP: &str = "fmt_zip";
pub const FMT_TAR: &str = "fmt_tar";
pub const FMT_TARGZ: &str = "fmt_targz";
pub const FMT_TARZST: &str = "fmt_tarzst";
pub const FILE_INFO: &str = "file_info";
pub const NAME: &str = "name";
pub const TYPE: &str = "type";
pub const FOLDER: &str = "folder";
pub const FILE: &str = "file";
pub const SIZE: &str = "size";
pub const MODIFIED: &str = "modified";
pub const LOCATION: &str = "location";
pub const CLOSE: &str = "close";
// Toasts
pub const FOLDER_CREATED: &str = "folder_created";
pub const FILE_CREATED: &str = "file_created";
pub const NAME_TAKEN: &str = "name_taken";
pub const RENAMED: &str = "renamed";
pub const DELETED: &str = "deleted";
pub const UPLOADING: &str = "uploading";
pub const SOME_FILES_EXIST: &str = "some_files_exist";
pub const UPLOAD_COMPLETE: &str = "upload_complete";
pub const MOVED: &str = "moved";
pub const COPIED: &str = "copied";
pub const DOWNLOAD_STARTED: &str = "download_started";
pub const SAVED: &str = "saved";
// Shell: sidebar, topbar, footer
pub const MENU: &str = "menu";
pub const OPEN_MENU: &str = "open_menu";
pub const REFRESH_FOLDERS: &str = "refresh_folders";
pub const THEME_TITLE: &str = "theme_title";
pub const LOG_OUT: &str = "log_out";
pub const FILES: &str = "files";
pub const MANAGE: &str = "manage";
pub const SHARES: &str = "shares";
pub const USERS: &str = "users";
pub const NO_USERS: &str = "no_users";
pub const SETTINGS: &str = "settings";
pub const THEME_AUTO: &str = "theme_auto";
pub const THEME_LIGHT: &str = "theme_light";
pub const THEME_DARK: &str = "theme_dark";
// Search view
pub const SEARCH: &str = "search";
pub const SEARCH_PLACEHOLDER: &str = "search_placeholder";
pub const SEARCH_NAME: &str = "search_name";
pub const SEARCH_CONTENT: &str = "search_content";
pub const SEARCH_BOTH: &str = "search_both";
pub const SEARCH_GOTO: &str = "search_goto";
pub const SEARCH_RUN: &str = "search_run";
pub const SEARCH_IN: &str = "search_in";
pub const SEARCH_HERE: &str = "search_here";
pub const SEARCH_IN_FOLDER: &str = "search_in_folder";
pub const SEARCH_STOP: &str = "search_stop";
pub const SEARCHING: &str = "searching";
pub const SEARCH_N_RESULTS: &str = "search_n_results";
pub const SEARCH_TOOK: &str = "search_took";
pub const SEARCH_STOPPED: &str = "search_stopped";
pub const SEARCH_SCANNED: &str = "search_scanned";
pub const SEARCH_SKIPPED: &str = "search_skipped";
pub const SEARCH_N_MATCHES: &str = "search_n_matches";
pub const SEARCH_IN_N_FILES: &str = "search_in_n_files";
pub const SEARCH_COLLAPSE_ALL: &str = "search_collapse_all";
pub const SEARCH_EXPAND_ALL: &str = "search_expand_all";
pub const SEARCH_SHOWN_OF: &str = "search_shown_of";
pub const SEARCH_SHOW_MORE: &str = "search_show_more";
pub const SEARCH_NO_RESULTS: &str = "search_no_results";
// Settings
pub const SAVING: &str = "saving";
pub const PROFILE: &str = "profile";
pub const SERVER: &str = "server";
pub const PROFILE_SAVED: &str = "profile_saved";
pub const PROFILE_SAVE_ERR: &str = "profile_save_err";
pub const SETTINGS_SAVED: &str = "settings_saved";
pub const SETTINGS_SAVE_ERR: &str = "settings_save_err";
pub const SINGLE_CLICK_LABEL: &str = "single_click_label";
pub const SINGLE_CLICK_DESC: &str = "single_click_desc";
pub const SAVE_PROFILE: &str = "save_profile";
pub const ALLOW_RW_SHARES: &str = "allow_rw_shares";
pub const ALLOW_RW_SHARES_DESC: &str = "allow_rw_shares_desc";
pub const SAVE_SETTINGS: &str = "save_settings";
pub const LANGUAGE: &str = "language";
pub const LANGUAGE_DESC: &str = "language_desc";
pub const LANG_AUTO: &str = "lang_auto";
pub const LANG_EN: &str = "lang_en";
pub const LANG_DE: &str = "lang_de";
pub const LANG_FR: &str = "lang_fr";
// Admin: users
pub const DELETE_USER_CONFIRM: &str = "delete_user_confirm";
pub const USER_DELETE_ERR: &str = "user_delete_err";
pub const NEW_USER: &str = "new_user";
pub const EDIT: &str = "edit";
pub const DELETE_USER: &str = "delete_user";
pub const CANT_DELETE_SELF: &str = "cant_delete_self";
pub const ROOT_DUP_ERR: &str = "root_dup_err";
pub const NAME_REQUIRED: &str = "name_required";
pub const PW_SHORT: &str = "pw_short";
pub const NEED_FOLDER: &str = "need_folder";
pub const USER_CREATED: &str = "user_created";
pub const USER_UPDATED: &str = "user_updated";
pub const USER_SAVE_ERR: &str = "user_save_err";
pub const PASSWORD: &str = "password";
pub const NEW_PASSWORD_KEEP: &str = "new_password_keep";
pub const ADMINISTRATOR: &str = "administrator";
pub const ACTIVE_CAN_SIGNIN: &str = "active_can_signin";
pub const REMOVE_FOLDER: &str = "remove_folder";
pub const CREATE_USER: &str = "create_user";
pub const EDIT_USER_TITLE: &str = "edit_user_title";
pub const FOLDERS: &str = "folders";
pub const FOLDERS_PATHS_HINT: &str = "folders_paths_hint";
pub const ADD_FOLDER: &str = "add_folder";
pub const ADD_HERE: &str = "add_here";
pub const SERVER_ROOT: &str = "server_root";
pub const SERVER_UNREACHABLE: &str = "server_unreachable";
pub const SEARCH_FAILED: &str = "search_failed";
pub const MODE_RW: &str = "mode_rw";
pub const EXPIRY_1H: &str = "expiry_1h";
pub const EXPIRY_1D: &str = "expiry_1d";
pub const EXPIRY_1W: &str = "expiry_1w";
pub const EXPIRY_1MO: &str = "expiry_1mo";
pub const META_EXPIRES: &str = "meta_expires";
pub const META_CREATED: &str = "meta_created";
pub const MODE_RO: &str = "mode_ro";
// Share page (public)
pub const SHARE_NOT_FOUND: &str = "share_not_found";
pub const SHARE_NOT_FOUND_MSG: &str = "share_not_found_msg";
pub const SHARE_EXPIRED: &str = "share_expired";
pub const SHARE_EXPIRED_MSG: &str = "share_expired_msg";
// Shares: dialog + management
pub const COPY_MANUAL: &str = "copy_manual";
pub const LINK_COPIED: &str = "link_copied";
pub const SHARE_CREATE_ERR: &str = "share_create_err";
pub const SHARE_LINK: &str = "share_link";
pub const SHARE_PERM_RW: &str = "share_perm_rw";
pub const SHARE_PERM_RO: &str = "share_perm_ro";
pub const SHARE_TITLE: &str = "share_title";
pub const SHARE_DIALOG_HINT: &str = "share_dialog_hint";
pub const EXPIRES: &str = "expires";
pub const NEVER: &str = "never";
pub const CUSTOM: &str = "custom";
pub const UNTIL: &str = "until";
pub const EXPIRES_AT: &str = "expires_at";
pub const DELETE_PARTIAL: &str = "delete_partial";
pub const NEVER_EXPIRES: &str = "never_expires";
pub const ALLOW_EDITING: &str = "allow_editing";
pub const RW_DISABLED: &str = "rw_disabled";
pub const RW_RO_FOLDER: &str = "rw_ro_folder";
pub const CREATING: &str = "creating";
pub const CREATE_LINK: &str = "create_link";
pub const YOUR_SHARES: &str = "your_shares";
pub const DELETED_N_SHARES: &str = "deleted_n_shares";
pub const SHARES_DELETE_ERR: &str = "shares_delete_err";
pub const DELETE_ALL: &str = "delete_all";
pub const NO_SHARES: &str = "no_shares";
pub const SHARES_VIEW_HINT: &str = "shares_view_hint";
pub const SHARES_EMPTY_HINT: &str = "shares_empty_hint";
pub const COPY_LINK: &str = "copy_link";
pub const DELETE_SHARE: &str = "delete_share";
pub const SHARE_DELETED: &str = "share_deleted";
pub const SHARE_DELETE_ERR: &str = "share_delete_err";
pub const DELETE_ALL_TITLE: &str = "delete_all_title";
pub const DELETE_ALL_MSG: &str = "delete_all_msg";
// Login / setup
pub const LOGIN_ERROR: &str = "login_error";
pub const LOGIN_SUBTITLE: &str = "login_subtitle";
pub const SIGN_IN: &str = "sign_in";
pub const SETUP_NAME_ERR: &str = "setup_name_err";
pub const SETUP_PW_MISMATCH: &str = "setup_pw_mismatch";
pub const REPEAT_PASSWORD: &str = "repeat_password";
pub const SETUP_INTRO: &str = "setup_intro";
pub const CREATE_ADMIN: &str = "create_admin";
// File view / editor
pub const SAVE: &str = "save";
pub const CLOSE_ESC: &str = "close_esc";
pub const NO_PREVIEW: &str = "no_preview";
pub const NO_PREVIEW_MSG: &str = "no_preview_msg";
pub const DISMISS: &str = "dismiss";
pub const DISMISS_ERROR: &str = "dismiss_error";
pub const FILE_CHANGED: &str = "file_changed";
pub const MODIFIED_AFTER: &str = "modified_after";
pub const LOAD_LATEST: &str = "load_latest";
pub const KEEP_EDITING: &str = "keep_editing";
pub const DISCARD: &str = "discard";
pub const UNSAVED_CHANGES: &str = "unsaved_changes";
pub const DISCARD_QUESTION: &str = "discard_question";
/// Declares the translation keys and the canonical English text in one
/// place: one row per key, `CONST = "key" => "English"`.
///
/// The `k` constants and the `EN` table are generated from the same row, so
/// a key cannot exist in one without the other. Rows are sorted by key,
/// because [`find`] binary-searches (`tables_are_sorted` pins that).
macro_rules! i18n_keys {
($($name:ident = $key:literal => $en:expr),* $(,)?) => {
/// Translation keys. Use these, never the raw strings: a typo in a
/// key is a compile error, a typo in a string is a runtime panic.
pub mod k {
$(pub const $name: &str = $key;)*
}
#[allow(dead_code)]
// Server error codes (see server/src for the constructors)
pub const ERR_INTERNAL: &str = "err_internal";
pub const ERR_NOT_SIGNED_IN: &str = "err_not_signed_in";
pub const ERR_SESSION_EXPIRED: &str = "err_session_expired";
pub const ERR_INVALID_CREDENTIALS: &str = "err_invalid_credentials";
pub const ERR_ALREADY_SET_UP: &str = "err_already_set_up";
pub const ERR_SHARE_NOT_FOUND: &str = "err_share_not_found";
pub const ERR_SHARE_EXPIRED: &str = "err_share_expired";
pub const ERR_SHARE_TOKEN_FORBIDDEN: &str = "err_share_token_forbidden";
pub const ERR_NAME_LENGTH: &str = "err_name_length";
pub const ERR_PASSWORD_SHORT: &str = "err_password_short";
pub const ERR_ADMIN_ONLY: &str = "err_admin_only";
pub const ERR_USER_EXISTS: &str = "err_user_exists";
pub const ERR_USER_NOT_FOUND: &str = "err_user_not_found";
pub const ERR_OWN_ADMIN: &str = "err_own_admin";
pub const ERR_OWN_ACCOUNT: &str = "err_own_account";
pub const ERR_LAST_ADMIN: &str = "err_last_admin";
pub const ERR_OWN_DELETE: &str = "err_own_delete";
pub const ERR_LAST_ADMIN_DELETE: &str = "err_last_admin_delete";
pub const ERR_BAD_FORMAT: &str = "err_bad_format";
pub const ERR_NOT_A_FILE: &str = "err_not_a_file";
pub const ERR_TOO_LARGE_PREVIEW: &str = "err_too_large_preview";
pub const ERR_BAD_ACTION: &str = "err_bad_action";
pub const ERR_TOO_LARGE_SAVE: &str = "err_too_large_save";
pub const ERR_BAD_BODY: &str = "err_bad_body";
pub const ERR_INVALID_LANGUAGE: &str = "err_invalid_language";
pub const ERR_BAD_POST: &str = "err_bad_post";
pub const ERR_FOLDER_NAME_REQUIRED: &str = "err_folder_name_required";
pub const ERR_FILE_NAME_REQUIRED: &str = "err_file_name_required";
pub const ERR_NEW_NAME_REQUIRED: &str = "err_new_name_required";
pub const ERR_DST_REQUIRED: &str = "err_dst_required";
pub const ERR_PATH_REQUIRED: &str = "err_path_required";
pub const ERR_BAD_MULTIPART: &str = "err_bad_multipart";
pub const ERR_BAD_UPLOAD: &str = "err_bad_upload";
pub const ERR_PART_NO_NAME: &str = "err_part_no_name";
pub const ERR_BAD_PART_NAME: &str = "err_bad_part_name";
pub const ERR_FOLDER_EXISTS: &str = "err_folder_exists";
pub const ERR_SAVE_FAILED: &str = "err_save_failed";
pub const ERR_NO_FILES_UPLOADED: &str = "err_no_files_uploaded";
pub const ERR_FILES_EXIST: &str = "err_files_exist";
pub const ERR_BAD_UPLOAD_PATH: &str = "err_bad_upload_path";
pub const ERR_NO_SUCH_FOLDER: &str = "err_no_such_folder";
pub const ERR_READ_ONLY_FOLDER: &str = "err_read_only_folder";
pub const ERR_RW_SHARES_DISABLED: &str = "err_rw_shares_disabled";
pub const ERR_RW_RO_FOLDER: &str = "err_rw_ro_folder";
pub const ERR_BAD_EXPIRES_AT: &str = "err_bad_expires_at";
pub const ERR_FS_NOT_FOUND: &str = "err_fs_not_found";
pub const ERR_FS_NOT_A_DIR: &str = "err_fs_not_a_dir";
pub const ERR_FS_FORBIDDEN: &str = "err_fs_forbidden";
pub const ERR_FS_ROOT_MISSING: &str = "err_fs_root_missing";
pub const ERR_FS_CONFLICT: &str = "err_fs_conflict";
pub const ERR_SEARCH_FORBIDDEN: &str = "err_search_forbidden";
pub const ERR_SEARCH_BAD_SCOPE: &str = "err_search_bad_scope";
pub const ERR_ROOT_FORBIDDEN: &str = "err_root_forbidden";
/// Canonical English strings (key → text). The source of truth for
/// which keys exist; DE and FR translate the same list.
const EN: &[(&str, &str)] = &[$(($key, $en)),*];
};
}
/// Canonical English strings (key → text). The source of truth for which
/// keys exist; DE and FR may be partial and fall back to English.
const EN: &[(&str, &str)] = &[
("active_can_signin", "Active (can sign in)"),
("add_folder", "Add folder…"),
("add_here", "Add this folder"),
("administrator", "Administrator"),
("allow_editing", "Allow editing (read-write)"),
("allow_rw_shares", "Allow writable shares"),
(
"allow_rw_shares_desc",
"Let users create read-write share links. Off by default.",
),
("and_more", "…and {} more"),
("cancel", "Cancel"),
("cant_delete_self", "You cannot delete yourself"),
("close", "Close"),
("close_esc", "Close (Esc)"),
("copied", "Copied"),
("copy", "Copy"),
("copy_here", "Copy here"),
("copy_link", "Copy link"),
("copy_manual", "Copy the link manually"),
("copy_n_to", "Copy {} items to…"),
("copy_to", "Copy “{}” to…"),
("create", "Create"),
("create_admin", "Create admin account"),
("create_link", "Create link"),
("create_user", "Create user"),
("creating", "Creating…"),
("custom", "Custom…"),
("delete", "Delete"),
("delete_all", "Delete all"),
(
"delete_all_msg",
"Delete all {} share links? Anyone holding them loses access. This cannot be undone.",
),
("delete_all_title", "Delete all shares?"),
(
"delete_folder_msg",
"Delete folder “{}” and everything inside it?\nThis cannot be undone.",
),
("delete_msg", "Delete “{}”?\nThis cannot be undone."),
("delete_n_msg", "Delete {} items?\nThis cannot be undone."),
("delete_partial", "{} of {} shares could not be deleted"),
("delete_share", "Delete share"),
("delete_user", "Delete user"),
(
"delete_user_confirm",
"Delete user “{}”? This cannot be undone.",
),
("deleted", "Deleted"),
("deleted_n_shares", "Deleted {} shares"),
("discard", "Discard"),
(
"discard_question",
"Discard your changes and close the editor?",
),
("dismiss", "Dismiss"),
("dismiss_error", "Dismiss error"),
("done", "Done"),
("download", "Download"),
(
"download_formats_msg",
"Choose an archive format for the folder.",
),
("download_started", "Download started"),
("download_title", "Download “{}”"),
("edit", "Edit"),
("edit_user_title", "Edit “{}”"),
("empty_folder", "This folder is empty."),
(k::ERR_ADMIN_ONLY, "admin only"),
(k::ERR_ALREADY_SET_UP, "server is already set up"),
(k::ERR_BAD_ACTION, "expected action=content"),
(k::ERR_BAD_BODY, "invalid request body"),
(k::ERR_BAD_EXPIRES_AT, "the expiry date is invalid"),
(
k::ERR_BAD_FORMAT,
"format must be one of: zip, tar, tar.gz, tar.zst",
),
(
k::ERR_BAD_MULTIPART,
"expected multipart/form-data with a boundary",
),
(k::ERR_BAD_PART_NAME, "invalid part name"),
(
k::ERR_BAD_POST,
"POST expects a multipart upload, a JSON mutation, or ?action=mkdir/create-file",
),
(k::ERR_BAD_UPLOAD, "invalid upload data"),
(k::ERR_BAD_UPLOAD_PATH, "invalid file path in upload"),
(k::ERR_DST_REQUIRED, "dst_root_id is required"),
(k::ERR_FILE_NAME_REQUIRED, "a file name is required"),
(k::ERR_FILES_EXIST, "some files already exist"),
(
k::ERR_FOLDER_EXISTS,
"a folder with this name already exists",
),
(k::ERR_FOLDER_NAME_REQUIRED, "a folder name is required"),
(k::ERR_FS_CONFLICT, "already exists"),
(k::ERR_FS_FORBIDDEN, "access denied"),
(k::ERR_FS_NOT_A_DIR, "not a folder"),
(k::ERR_FS_NOT_FOUND, "folder not found"),
(
k::ERR_FS_ROOT_MISSING,
"the configured folder no longer exists",
),
(k::ERR_INTERNAL, "internal error"),
(k::ERR_INVALID_CREDENTIALS, "invalid name or password"),
(k::ERR_INVALID_LANGUAGE, "invalid language tag"),
(k::ERR_LAST_ADMIN, "cannot remove the last active admin"),
(
k::ERR_LAST_ADMIN_DELETE,
"cannot delete the last active admin",
),
(k::ERR_NAME_LENGTH, "name must be 1–64 characters"),
(k::ERR_NEW_NAME_REQUIRED, "new_name is required"),
(k::ERR_NO_FILES_UPLOADED, "no files were uploaded"),
(k::ERR_NO_SUCH_FOLDER, "no such folder"),
(k::ERR_NOT_A_FILE, "not a file"),
(k::ERR_NOT_SIGNED_IN, "not signed in"),
(k::ERR_OWN_ACCOUNT, "you cannot disable your own account"),
(k::ERR_OWN_ADMIN, "you cannot remove your own admin rights"),
(k::ERR_OWN_DELETE, "you cannot delete your own account"),
(k::ERR_PART_NO_NAME, "part without a name"),
(
k::ERR_PASSWORD_SHORT,
"password must be at least 8 characters",
),
(k::ERR_PATH_REQUIRED, "a path inside the folder is required"),
(k::ERR_READ_ONLY_FOLDER, "read-only folder"),
(k::ERR_ROOT_FORBIDDEN, "root not accessible"),
(
k::ERR_RW_RO_FOLDER,
"this folder is read-only for you, so it cannot be shared writably",
),
(k::ERR_RW_SHARES_DISABLED, "writable shares are disabled"),
(k::ERR_SAVE_FAILED, "could not save the file"),
(
k::ERR_SEARCH_BAD_SCOPE,
"scope must be name, content or both",
),
(
k::ERR_SEARCH_FORBIDDEN,
"search requires a signed-in session",
),
(
k::ERR_SESSION_EXPIRED,
"session expired, please sign in again",
),
(k::ERR_SHARE_EXPIRED, "this share has expired"),
(k::ERR_SHARE_NOT_FOUND, "share not found"),
(
k::ERR_SHARE_TOKEN_FORBIDDEN,
"a share token cannot be used here; sign in instead",
),
(k::ERR_TOO_LARGE_PREVIEW, "file too large to preview"),
(k::ERR_TOO_LARGE_SAVE, "file too large to save"),
(k::ERR_USER_EXISTS, "a user with that name already exists"),
(k::ERR_USER_NOT_FOUND, "user not found"),
("expires", "Expires"),
("expires_at", "Expires {}"),
("expiry_1d", "1 day"),
("expiry_1h", "1 hour"),
("expiry_1mo", "1 month"),
("expiry_1w", "1 week"),
("file", "File"),
("file_changed", "File changed on disk"),
("file_created", "File created"),
("file_info", "File info"),
("file_name", "File name"),
("files", "Files"),
("fmt_tar", "Tar"),
("fmt_targz", "Tar + gzip"),
("fmt_tarzst", "Tar + zstd"),
("fmt_zip", "Zip"),
("folder", "Folder"),
("folder_created", "Folder created"),
(
"folder_gone_msg",
"This folder is no longer accessible. Choose another one from the sidebar.",
),
("folder_gone_title", "Folder not available"),
("folder_name", "Folder name"),
("folders", "Folders"),
(
"folders_paths_hint",
"Paths are relative to the server root. “.” is the whole root.",
),
("grid_view", "Grid view"),
("info", "Info"),
("keep_editing", "Keep editing"),
("lang_auto", "Automatic"),
("lang_de", "German"),
("lang_en", "English"),
("lang_fr", "French"),
("language", "Language"),
(
"language_desc",
"The interface language. Automatic uses your browser's language.",
),
("link_copied", "Link copied to clipboard"),
("list_truncated", "Only the first {} entries are shown."),
("list_view", "List view"),
("load_latest", "Load latest"),
("loading", "Loading…"),
("loading_share", "Loading share…"),
("location", "Location"),
("log_out", "Log out"),
("login_error", "Please enter your name and password."),
("login_subtitle", "Sign in to continue."),
("manage", "Manage"),
("menu", "Menu"),
("meta_created", "created {}"),
("meta_expires", "expires {}"),
("mode_ro", "read-only"),
("mode_rw", "read-write"),
("modified", "Modified"),
(
"modified_after",
"This file was modified after you opened it. “Load latest” discards your unsaved changes; “Overwrite” keeps your version.",
),
("more_actions", "More actions"),
("move", "Move"),
("move_here", "Move here"),
("move_n_to", "Move {} items to…"),
("move_to", "Move “{}” to…"),
("moved", "Moved"),
("name", "Name"),
("name_required", "A name is required."),
("name_taken", "Name already in use"),
("need_folder", "Give the user at least one folder."),
("never", "Never"),
("never_expires", "Never expires"),
("new_file", "New file"),
("new_folder", "New folder"),
("new_name", "New name"),
("new_password_keep", "New password (leave blank to keep)"),
("new_user", "New user"),
(
"no_folders_msg",
"An administrator hasn't assigned you any folders yet.",
),
("no_folders_title", "No folders available"),
("no_preview", "No preview available"),
("no_preview_msg", "The app can’t display “{}” here."),
("no_shares", "No shares yet"),
("no_subfolders", "No subfolders"),
("no_users", "No users."),
("open", "Open"),
("open_in_new_tab", "Open in new tab"),
("open_menu", "Open menu"),
("overwrite", "Overwrite"),
(
"overwrite_question",
"The following files already exist. Overwrite them?",
),
("password", "Password"),
("profile", "Profile"),
("profile_save_err", "Could not save profile: {}"),
("profile_saved", "Profile saved"),
("pw_short", "Password must be at least 8 characters."),
("read_only_badge", "Read-only"),
("read_only_hint", "Read-only folder"),
("refresh", "Refresh"),
("refresh_folders", "Refresh your folders"),
("remove_folder", "Remove folder"),
("rename", "Rename"),
("rename_title", "Rename “{}”"),
("renamed", "Renamed"),
("repeat_password", "Repeat password"),
("retry", "Retry"),
("root_dup_err", "That folder is already in the list."),
("rw_disabled", "Writable shares are disabled by the server."),
(
"rw_ro_folder",
"This folder is read-only, so the share is read-only too.",
),
("save", "Save"),
("save_profile", "Save profile"),
("save_settings", "Save settings"),
("saved", "Saved"),
("saving", "Saving…"),
("search", "Search"),
("search_both", "Both"),
("search_collapse_all", "Collapse all"),
("search_content", "Content"),
("search_expand_all", "Expand all"),
("search_failed", "The search failed."),
("search_goto", "Show in folder"),
("search_here", "Search here"),
("search_in", "Search in…"),
("search_in_folder", "Search in this folder"),
("search_in_n_files", "in {} files"),
("search_n_matches", "{} matches"),
("search_n_results", "{} results"),
("search_name", "Name"),
("search_no_results", "no results"),
("search_placeholder", "Search files and their contents"),
("search_run", "Search"),
("search_scanned", "scanned {} files"),
("search_show_more", "Show {} more"),
("search_shown_of", "Showing {} of {}"),
("search_skipped", "{} skipped (over 10 MiB)"),
("search_stop", "Stop"),
("search_stopped", "stopped · {} results"),
("search_took", "{} ms"),
("searching", "searching…"),
(
"select_folder_msg",
"Choose a folder from the sidebar to start browsing.",
),
("select_folder_title", "Select a folder"),
("server", "Server"),
("server_root", "Server root"),
("server_unreachable", "The server is not reachable."),
("settings", "Settings"),
("settings_save_err", "Could not save settings: {}"),
("settings_saved", "Settings saved"),
(
"setup_intro",
"First boot: create the admin account. This user can manage other users and server settings.",
),
("setup_name_err", "Please choose a name (1–64 characters)."),
("setup_pw_mismatch", "Passwords don't match."),
("share", "Share"),
("share_create_err", "Could not create share: {}"),
("share_delete_err", "Could not delete share"),
("share_deleted", "Share deleted"),
(
"share_dialog_hint",
"Create a link for this item. Writable links let viewers upload and edit.",
),
("share_expired", "Share expired"),
("share_expired_msg", "This share link is no longer active."),
("share_link", "Share link"),
("share_not_found", "Share not found"),
(
"share_not_found_msg",
"This link is invalid or the share was removed.",
),
("share_perm_ro", "Anyone with this link can read “{}”."),
(
"share_perm_rw",
"Anyone with this link can read and write “{}”.",
),
("share_title", "Share {}"),
("shares", "Shares"),
("shares_delete_err", "Could not delete shares"),
(
"shares_empty_hint",
"Right-click a file or folder and choose “Share” to create a link.",
),
(
"shares_view_hint",
"Links you created. Right-click a file or folder and choose “Share” to add one.",
),
("sign_in", "Sign in"),
(
"single_click_desc",
"When on, a single click opens an entry and Ctrl+click selects it. When off, a single click selects and a double click opens.",
),
("single_click_label", "Use single click to open"),
("size", "Size"),
("some_files_exist", "Some files already exist"),
("theme_auto", "Auto"),
("theme_dark", "Dark"),
("theme_light", "Light"),
("theme_title", "Theme: {} (click to switch)"),
("type", "Type"),
("unsaved_changes", "Unsaved changes"),
("until", "Until"),
("upload_complete", "Upload complete"),
("upload_files", "Upload files"),
("upload_folder", "Upload folder"),
("uploading", "Uploading {} files…"),
("user_created", "User created"),
("user_delete_err", "Could not delete user"),
("user_save_err", "Could not save user: {}"),
("user_updated", "User updated"),
("users", "Users"),
("your_shares", "Your shares"),
];
i18n_keys! {
ACTIVE_CAN_SIGNIN = "active_can_signin" => "Active (can sign in)",
ADD_FOLDER = "add_folder" => "Add folder…",
ADD_HERE = "add_here" => "Add this folder",
ADMINISTRATOR = "administrator" => "Administrator",
ALLOW_EDITING = "allow_editing" => "Allow editing (read-write)",
ALLOW_RW_SHARES = "allow_rw_shares" => "Allow writable shares",
ALLOW_RW_SHARES_DESC = "allow_rw_shares_desc" => "Let users create read-write share links. Off by default.",
AND_MORE = "and_more" => "…and {} more",
CANCEL = "cancel" => "Cancel",
CANT_DELETE_SELF = "cant_delete_self" => "You cannot delete yourself",
CLOSE = "close" => "Close",
CLOSE_ESC = "close_esc" => "Close (Esc)",
COPIED = "copied" => "Copied",
COPY = "copy" => "Copy",
COPY_HERE = "copy_here" => "Copy here",
COPY_LINK = "copy_link" => "Copy link",
COPY_MANUAL = "copy_manual" => "Copy the link manually",
COPY_N_TO = "copy_n_to" => "Copy {} items to…",
COPY_TO = "copy_to" => "Copy “{}” to…",
CREATE = "create" => "Create",
CREATE_ADMIN = "create_admin" => "Create admin account",
CREATE_LINK = "create_link" => "Create link",
CREATE_USER = "create_user" => "Create user",
CREATING = "creating" => "Creating…",
CUSTOM = "custom" => "Custom…",
DELETE = "delete" => "Delete",
DELETE_ALL = "delete_all" => "Delete all",
DELETE_ALL_MSG = "delete_all_msg" => "Delete all {} share links? Anyone holding them loses access. This cannot be undone.",
DELETE_ALL_TITLE = "delete_all_title" => "Delete all shares?",
DELETE_FOLDER_MSG = "delete_folder_msg" => "Delete folder “{}” and everything inside it?\nThis cannot be undone.",
DELETE_MSG = "delete_msg" => "Delete “{}”?\nThis cannot be undone.",
DELETE_N_MSG = "delete_n_msg" => "Delete {} items?\nThis cannot be undone.",
DELETE_PARTIAL = "delete_partial" => "{} of {} shares could not be deleted",
DELETE_SHARE = "delete_share" => "Delete share",
DELETE_USER = "delete_user" => "Delete user",
DELETE_USER_CONFIRM = "delete_user_confirm" => "Delete user “{}”? This cannot be undone.",
DELETED = "deleted" => "Deleted",
DELETED_N_SHARES = "deleted_n_shares" => "Deleted {} shares",
DISCARD = "discard" => "Discard",
DISCARD_QUESTION = "discard_question" => "Discard your changes and close the editor?",
DISMISS = "dismiss" => "Dismiss",
DISMISS_ERROR = "dismiss_error" => "Dismiss error",
DONE = "done" => "Done",
DOWNLOAD = "download" => "Download",
DOWNLOAD_FORMATS_MSG = "download_formats_msg" => "Choose an archive format for the folder.",
DOWNLOAD_STARTED = "download_started" => "Download started",
DOWNLOAD_TITLE = "download_title" => "Download “{}”",
EDIT = "edit" => "Edit",
EDIT_USER_TITLE = "edit_user_title" => "Edit “{}”",
EMPTY_FOLDER = "empty_folder" => "This folder is empty.",
ERR_ADMIN_ONLY = "err_admin_only" => "admin only",
ERR_ALREADY_SET_UP = "err_already_set_up" => "server is already set up",
ERR_BAD_ACTION = "err_bad_action" => "expected action=content",
ERR_BAD_BODY = "err_bad_body" => "invalid request body",
ERR_BAD_EXPIRES_AT = "err_bad_expires_at" => "the expiry date is invalid",
ERR_BAD_FORMAT = "err_bad_format" => "format must be one of: zip, tar, tar.gz, tar.zst",
ERR_BAD_MULTIPART = "err_bad_multipart" => "expected multipart/form-data with a boundary",
ERR_BAD_PART_NAME = "err_bad_part_name" => "invalid part name",
ERR_BAD_POST = "err_bad_post" => "POST expects a multipart upload, a JSON mutation, or ?action=mkdir/create-file",
ERR_BAD_UPLOAD = "err_bad_upload" => "invalid upload data",
ERR_BAD_UPLOAD_PATH = "err_bad_upload_path" => "invalid file path in upload",
ERR_DST_REQUIRED = "err_dst_required" => "dst_root_id is required",
ERR_FILE_NAME_REQUIRED = "err_file_name_required" => "a file name is required",
ERR_FILES_EXIST = "err_files_exist" => "some files already exist",
ERR_FOLDER_EXISTS = "err_folder_exists" => "a folder with this name already exists",
ERR_FOLDER_NAME_REQUIRED = "err_folder_name_required" => "a folder name is required",
ERR_FS_CONFLICT = "err_fs_conflict" => "already exists",
ERR_FS_FORBIDDEN = "err_fs_forbidden" => "access denied",
ERR_FS_NOT_A_DIR = "err_fs_not_a_dir" => "not a folder",
ERR_FS_NOT_FOUND = "err_fs_not_found" => "folder not found",
ERR_FS_ROOT_MISSING = "err_fs_root_missing" => "the configured folder no longer exists",
ERR_INTERNAL = "err_internal" => "internal error",
ERR_INVALID_CREDENTIALS = "err_invalid_credentials" => "invalid name or password",
ERR_INVALID_LANGUAGE = "err_invalid_language" => "invalid language tag",
ERR_LAST_ADMIN = "err_last_admin" => "cannot remove the last active admin",
ERR_LAST_ADMIN_DELETE = "err_last_admin_delete" => "cannot delete the last active admin",
ERR_NAME_LENGTH = "err_name_length" => "name must be 1–64 characters",
ERR_NEW_NAME_REQUIRED = "err_new_name_required" => "new_name is required",
ERR_NO_FILES_UPLOADED = "err_no_files_uploaded" => "no files were uploaded",
ERR_NO_SUCH_FOLDER = "err_no_such_folder" => "no such folder",
ERR_NOT_A_FILE = "err_not_a_file" => "not a file",
ERR_NOT_SIGNED_IN = "err_not_signed_in" => "not signed in",
ERR_OWN_ACCOUNT = "err_own_account" => "you cannot disable your own account",
ERR_OWN_ADMIN = "err_own_admin" => "you cannot remove your own admin rights",
ERR_OWN_DELETE = "err_own_delete" => "you cannot delete your own account",
ERR_PART_NO_NAME = "err_part_no_name" => "part without a name",
ERR_PASSWORD_SHORT = "err_password_short" => "password must be at least 8 characters",
ERR_PATH_REQUIRED = "err_path_required" => "a path inside the folder is required",
ERR_READ_ONLY_FOLDER = "err_read_only_folder" => "read-only folder",
ERR_ROOT_FORBIDDEN = "err_root_forbidden" => "root not accessible",
ERR_RW_RO_FOLDER = "err_rw_ro_folder" => "this folder is read-only for you, so it cannot be shared writably",
ERR_RW_SHARES_DISABLED = "err_rw_shares_disabled" => "writable shares are disabled",
ERR_SAVE_FAILED = "err_save_failed" => "could not save the file",
ERR_SEARCH_BAD_SCOPE = "err_search_bad_scope" => "scope must be name, content or both",
ERR_SEARCH_FORBIDDEN = "err_search_forbidden" => "search requires a signed-in session",
ERR_SESSION_EXPIRED = "err_session_expired" => "session expired, please sign in again",
ERR_SHARE_EXPIRED = "err_share_expired" => "this share has expired",
ERR_SHARE_NOT_FOUND = "err_share_not_found" => "share not found",
ERR_SHARE_TOKEN_FORBIDDEN = "err_share_token_forbidden" => "a share token cannot be used here; sign in instead",
ERR_TOO_LARGE_PREVIEW = "err_too_large_preview" => "file too large to preview",
ERR_TOO_LARGE_SAVE = "err_too_large_save" => "file too large to save",
ERR_USER_EXISTS = "err_user_exists" => "a user with that name already exists",
ERR_USER_NOT_FOUND = "err_user_not_found" => "user not found",
EXPIRES = "expires" => "Expires",
EXPIRES_AT = "expires_at" => "Expires {}",
EXPIRY_1D = "expiry_1d" => "1 day",
EXPIRY_1H = "expiry_1h" => "1 hour",
EXPIRY_1MO = "expiry_1mo" => "1 month",
EXPIRY_1W = "expiry_1w" => "1 week",
FILE = "file" => "File",
FILE_CHANGED = "file_changed" => "File changed on disk",
FILE_CREATED = "file_created" => "File created",
FILE_INFO = "file_info" => "File info",
FILE_NAME = "file_name" => "File name",
FILES = "files" => "Files",
FMT_TAR = "fmt_tar" => "Tar",
FMT_TARGZ = "fmt_targz" => "Tar + gzip",
FMT_TARZST = "fmt_tarzst" => "Tar + zstd",
FMT_ZIP = "fmt_zip" => "Zip",
FOLDER = "folder" => "Folder",
FOLDER_CREATED = "folder_created" => "Folder created",
FOLDER_GONE_MSG = "folder_gone_msg" => "This folder is no longer accessible. Choose another one from the sidebar.",
FOLDER_GONE_TITLE = "folder_gone_title" => "Folder not available",
FOLDER_NAME = "folder_name" => "Folder name",
FOLDERS = "folders" => "Folders",
FOLDERS_PATHS_HINT = "folders_paths_hint" => "Paths are relative to the server root. “.” is the whole root.",
GRID_VIEW = "grid_view" => "Grid view",
INFO = "info" => "Info",
KEEP_EDITING = "keep_editing" => "Keep editing",
LANG_AUTO = "lang_auto" => "Automatic",
LANG_DE = "lang_de" => "German",
LANG_EN = "lang_en" => "English",
LANG_FR = "lang_fr" => "French",
LANGUAGE = "language" => "Language",
LANGUAGE_DESC = "language_desc" => "The interface language. Automatic uses your browser's language.",
LINK_COPIED = "link_copied" => "Link copied to clipboard",
LIST_TRUNCATED = "list_truncated" => "Only the first {} entries are shown.",
LIST_VIEW = "list_view" => "List view",
LOAD_LATEST = "load_latest" => "Load latest",
LOADING = "loading" => "Loading…",
LOADING_SHARE = "loading_share" => "Loading share…",
LOCATION = "location" => "Location",
LOG_OUT = "log_out" => "Log out",
LOGIN_ERROR = "login_error" => "Please enter your name and password.",
LOGIN_SUBTITLE = "login_subtitle" => "Sign in to continue.",
MANAGE = "manage" => "Manage",
MENU = "menu" => "Menu",
META_CREATED = "meta_created" => "created {}",
META_EXPIRES = "meta_expires" => "expires {}",
MODE_RO = "mode_ro" => "read-only",
MODE_RW = "mode_rw" => "read-write",
MODIFIED = "modified" => "Modified",
MODIFIED_AFTER = "modified_after" => "This file was modified after you opened it. “Load latest” discards your unsaved changes; “Overwrite” keeps your version.",
MORE_ACTIONS = "more_actions" => "More actions",
MOVE = "move" => "Move",
MOVE_HERE = "move_here" => "Move here",
MOVE_N_TO = "move_n_to" => "Move {} items to…",
MOVE_TO = "move_to" => "Move “{}” to…",
MOVED = "moved" => "Moved",
NAME = "name" => "Name",
NAME_REQUIRED = "name_required" => "A name is required.",
NAME_TAKEN = "name_taken" => "Name already in use",
NEED_FOLDER = "need_folder" => "Give the user at least one folder.",
NEVER = "never" => "Never",
NEVER_EXPIRES = "never_expires" => "Never expires",
NEW_FILE = "new_file" => "New file",
NEW_FOLDER = "new_folder" => "New folder",
NEW_NAME = "new_name" => "New name",
NEW_PASSWORD_KEEP = "new_password_keep" => "New password (leave blank to keep)",
NEW_USER = "new_user" => "New user",
NO_FOLDERS_MSG = "no_folders_msg" => "An administrator hasn't assigned you any folders yet.",
NO_FOLDERS_TITLE = "no_folders_title" => "No folders available",
NO_PREVIEW = "no_preview" => "No preview available",
NO_PREVIEW_MSG = "no_preview_msg" => "The app can’t display “{}” here.",
NO_SHARES = "no_shares" => "No shares yet",
NO_SUBFOLDERS = "no_subfolders" => "No subfolders",
NO_USERS = "no_users" => "No users.",
OPEN = "open" => "Open",
OPEN_IN_NEW_TAB = "open_in_new_tab" => "Open in new tab",
OPEN_MENU = "open_menu" => "Open menu",
OVERWRITE = "overwrite" => "Overwrite",
OVERWRITE_QUESTION = "overwrite_question" => "The following files already exist. Overwrite them?",
PASSWORD = "password" => "Password",
PROFILE = "profile" => "Profile",
PROFILE_SAVE_ERR = "profile_save_err" => "Could not save profile: {}",
PROFILE_SAVED = "profile_saved" => "Profile saved",
PW_SHORT = "pw_short" => "Password must be at least 8 characters.",
READ_ONLY_BADGE = "read_only_badge" => "Read-only",
READ_ONLY_HINT = "read_only_hint" => "Read-only folder",
REFRESH = "refresh" => "Refresh",
REFRESH_FOLDERS = "refresh_folders" => "Refresh your folders",
REMOVE_FOLDER = "remove_folder" => "Remove folder",
RENAME = "rename" => "Rename",
RENAME_TITLE = "rename_title" => "Rename “{}”",
RENAMED = "renamed" => "Renamed",
REPEAT_PASSWORD = "repeat_password" => "Repeat password",
RETRY = "retry" => "Retry",
ROOT_DUP_ERR = "root_dup_err" => "That folder is already in the list.",
RW_DISABLED = "rw_disabled" => "Writable shares are disabled by the server.",
RW_RO_FOLDER = "rw_ro_folder" => "This folder is read-only, so the share is read-only too.",
SAVE = "save" => "Save",
SAVE_PROFILE = "save_profile" => "Save profile",
SAVE_SETTINGS = "save_settings" => "Save settings",
SAVED = "saved" => "Saved",
SAVING = "saving" => "Saving…",
SEARCH = "search" => "Search",
SEARCH_BOTH = "search_both" => "Both",
SEARCH_COLLAPSE_ALL = "search_collapse_all" => "Collapse all",
SEARCH_CONTENT = "search_content" => "Content",
SEARCH_EXPAND_ALL = "search_expand_all" => "Expand all",
SEARCH_FAILED = "search_failed" => "The search failed.",
SEARCH_GOTO = "search_goto" => "Show in folder",
SEARCH_HERE = "search_here" => "Search here",
SEARCH_IN = "search_in" => "Search in…",
SEARCH_IN_FOLDER = "search_in_folder" => "Search in this folder",
SEARCH_IN_N_FILES = "search_in_n_files" => "in {} files",
SEARCH_N_MATCHES = "search_n_matches" => "{} matches",
SEARCH_N_RESULTS = "search_n_results" => "{} results",
SEARCH_NAME = "search_name" => "Name",
SEARCH_NO_RESULTS = "search_no_results" => "no results",
SEARCH_PLACEHOLDER = "search_placeholder" => "Search files and their contents",
SEARCH_RUN = "search_run" => "Search",
SEARCH_SCANNED = "search_scanned" => "scanned {} files",
SEARCH_SHOW_MORE = "search_show_more" => "Show {} more",
SEARCH_SHOWN_OF = "search_shown_of" => "Showing {} of {}",
SEARCH_SKIPPED = "search_skipped" => "{} skipped (over 10 MiB)",
SEARCH_STOP = "search_stop" => "Stop",
SEARCH_STOPPED = "search_stopped" => "stopped · {} results",
SEARCH_TOOK = "search_took" => "{} ms",
SEARCHING = "searching" => "searching…",
SELECT_FOLDER_MSG = "select_folder_msg" => "Choose a folder from the sidebar to start browsing.",
SELECT_FOLDER_TITLE = "select_folder_title" => "Select a folder",
SERVER = "server" => "Server",
SERVER_ROOT = "server_root" => "Server root",
SERVER_UNREACHABLE = "server_unreachable" => "The server is not reachable.",
SETTINGS = "settings" => "Settings",
SETTINGS_SAVE_ERR = "settings_save_err" => "Could not save settings: {}",
SETTINGS_SAVED = "settings_saved" => "Settings saved",
SETUP_INTRO = "setup_intro" => "First boot: create the admin account. This user can manage other users and server settings.",
SETUP_NAME_ERR = "setup_name_err" => "Please choose a name (1–64 characters).",
SETUP_PW_MISMATCH = "setup_pw_mismatch" => "Passwords don't match.",
SHARE = "share" => "Share",
SHARE_CREATE_ERR = "share_create_err" => "Could not create share: {}",
SHARE_DELETE_ERR = "share_delete_err" => "Could not delete share",
SHARE_DELETED = "share_deleted" => "Share deleted",
SHARE_DIALOG_HINT = "share_dialog_hint" => "Create a link for this item. Writable links let viewers upload and edit.",
SHARE_EXPIRED = "share_expired" => "Share expired",
SHARE_EXPIRED_MSG = "share_expired_msg" => "This share link is no longer active.",
SHARE_LINK = "share_link" => "Share link",
SHARE_NOT_FOUND = "share_not_found" => "Share not found",
SHARE_NOT_FOUND_MSG = "share_not_found_msg" => "This link is invalid or the share was removed.",
SHARE_PERM_RO = "share_perm_ro" => "Anyone with this link can read “{}”.",
SHARE_PERM_RW = "share_perm_rw" => "Anyone with this link can read and write “{}”.",
SHARE_TITLE = "share_title" => "Share {}",
SHARES = "shares" => "Shares",
SHARES_DELETE_ERR = "shares_delete_err" => "Could not delete shares",
SHARES_EMPTY_HINT = "shares_empty_hint" => "Right-click a file or folder and choose “Share” to create a link.",
SHARES_VIEW_HINT = "shares_view_hint" => "Links you created. Right-click a file or folder and choose “Share” to add one.",
SIGN_IN = "sign_in" => "Sign in",
SINGLE_CLICK_DESC = "single_click_desc" => "When on, a single click opens an entry and Ctrl+click selects it. When off, a single click selects and a double click opens.",
SINGLE_CLICK_LABEL = "single_click_label" => "Use single click to open",
SIZE = "size" => "Size",
SOME_FILES_EXIST = "some_files_exist" => "Some files already exist",
THEME_AUTO = "theme_auto" => "Auto",
THEME_DARK = "theme_dark" => "Dark",
THEME_LIGHT = "theme_light" => "Light",
THEME_TITLE = "theme_title" => "Theme: {} (click to switch)",
TYPE = "type" => "Type",
UNSAVED_CHANGES = "unsaved_changes" => "Unsaved changes",
UNTIL = "until" => "Until",
UPLOAD_COMPLETE = "upload_complete" => "Upload complete",
UPLOAD_FILES = "upload_files" => "Upload files",
UPLOAD_FOLDER = "upload_folder" => "Upload folder",
UPLOADING = "uploading" => "Uploading {} files…",
USER_CREATED = "user_created" => "User created",
USER_DELETE_ERR = "user_delete_err" => "Could not delete user",
USER_SAVE_ERR = "user_save_err" => "Could not save user: {}",
USER_UPDATED = "user_updated" => "User updated",
USERS = "users" => "Users",
YOUR_SHARES = "your_shares" => "Your shares",
}
/// German translations.
const DE: &[(&str, &str)] = &[
@@ -1825,43 +1430,4 @@ mod tests {
}
}
}
/// The parity test above only compares the tables against each other, so
/// a key missing from *all* of them (e.g. a `k` constant added without
/// its translations) slips through. Scan the `k` module's source for
/// `pub const NAME: &str = "key";` and require every key in `EN`.
#[test]
fn all_k_constants_exist_in_en() {
let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/i18n.rs"))
.expect("read i18n.rs");
let k_start = src.find("pub mod k {").expect("k module");
// The k module's closing brace is the first column-0 `}` after it.
let k_end = k_start + src[k_start..].find("\n}").expect("k module end");
// Lines look like: `pub const NAME: &str = "key";`
let parse_line = |l: &str| {
let l = l.trim();
let mut parts = l.splitn(3, char::is_whitespace);
let _kw = parts.next()?;
let _const_kw = parts.next()?;
let rest = parts.next()?; // `NAME: &str = "key";`
let (name_ty, value) = rest.split_once('=')?;
let ty = name_ty.rsplit_once(':')?.1.trim();
let val = value.trim().trim_end_matches(';').trim().trim_matches('"');
(ty == "&str").then_some(val.to_string())
};
let keys: Vec<String> = src[k_start..k_end]
.lines()
.filter_map(parse_line)
.filter(|k| !k.is_empty())
.collect();
assert!(keys.len() > 100, "scan found only {} keys", keys.len());
let missing: Vec<&String> = keys
.iter()
.filter(|k| EN.iter().all(|(t, _)| *t != k.as_str()))
.collect();
assert!(
missing.is_empty(),
"k constants without an EN translation: {missing:?}"
);
}
}
▾Mweb/src/router.rs
@@ -32,6 +32,19 @@ impl Section {
Section::Settings => Some("settings"),
}
}
/// The inverse of [`segment`]: the section a URL segment names. `files`
/// has no segment, so it is never returned here.
fn from_segment(seg: &str) -> Option<Self> {
[
Section::Search,
Section::Shares,
Section::Users,
Section::Settings,
]
.into_iter()
.find(|s| s.segment() == Some(seg))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -90,12 +103,6 @@ pub fn parse_location() -> Location {
},
None => Location::root(),
},
Some("search") => Location {
root_id: None,
path: Vec::new(),
share_token: None,
section: Section::Search,
},
// `#/share/{token}/...` — public share page.
Some("share") => match parts.next() {
Some(token) => Location {
@@ -107,26 +114,15 @@ pub fn parse_location() -> Location {
// `#/share` without a token is not a share; treat it as home.
None => Location::root(),
},
// `#/shares` — the signed-in shares manage view.
Some("shares") => Location {
root_id: None,
path: Vec::new(),
share_token: None,
section: Section::Shares,
},
Some("users") => Location {
root_id: None,
path: Vec::new(),
share_token: None,
section: Section::Users,
},
Some("settings") => Location {
root_id: None,
path: Vec::new(),
share_token: None,
section: Section::Settings,
// `#/search`, `#/shares`, `#/users`, `#/settings` — the manage views.
// Each is just a section; none carries a root or a path.
seg => match seg.and_then(Section::from_segment) {
Some(section) => Location {
section,
..Location::root()
},
None => Location::root(),
},
_ => Location::root(),
}
}
▾Mweb/src/util.rs
@@ -58,6 +58,13 @@ pub fn owned_window_listener_capture<E: leptos::ev::EventDescriptor + 'static>(
});
}
/// The value of a `<select>` behind an event, when the target is one.
pub fn select_value(ev: &web_sys::Event) -> Option<String> {
ev.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
.map(|s| s.value())
}
pub fn format_size(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut v = bytes as f64;
▾Mweb/src/views/admin.rs
@@ -2,15 +2,9 @@
//! Rendered as full views from the shell's "manage" section.
use crate::i18n;
use crate::util::select_value;
use leptos::prelude::*;
use wasm_bindgen::JsCast;
/// The value of a `<select>` behind an event, when the target is one.
fn select_value(ev: &web_sys::Event) -> Option<String> {
ev.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
.map(|s| s.value())
}
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, AdminUser, Me, Mode};
▾Mweb/src/views/browser.rs
@@ -457,37 +457,20 @@ fn file_browser(
}
let total = list.entries.len();
let truncated = list.truncated;
let body = if view_mode.get() == ViewMode::Grid {
grid_view(
list,
shown,
root_id,
loc,
is_rw,
single_click,
selected,
sel_names,
set_selected,
set_ctx,
open_file,
)
.into_any()
} else {
list_view(
list,
shown,
root_id,
loc,
is_rw,
single_click,
selected,
sel_names,
set_selected,
set_ctx,
open_file,
)
.into_any()
};
let body = entries_view(
view_mode.get(),
list,
shown,
root_id,
loc,
is_rw,
single_click,
selected,
sel_names,
set_selected,
set_ctx,
open_file,
);
// Paging footer and, below it, the server-side cap
// notice — both belong to the listing, not to one
// of the two layouts.
@@ -713,8 +696,13 @@ fn plain_dblclick(ev: &MouseEvent) -> bool {
!(ev.ctrl_key() || ev.meta_key() || ev.shift_key())
}
/// One listing layout. Grid and list differ only in their container and
/// item classes and in what each item shows, so they are one function: the
/// selection, keyboard and context-menu behaviour is identical and must
/// stay that way.
#[allow(clippy::too_many_arguments)] // explicit signal props
fn grid_view(
fn entries_view(
mode: ViewMode,
all: Arc<Listing>,
shown: RwSignal<usize>,
root_id: i64,
@@ -728,12 +716,18 @@ fn grid_view(
open_file: WriteSignal<Option<FileView>>,
) -> impl IntoView {
let total = all.entries.len();
let grid = mode == ViewMode::Grid;
let (container, item, icon_class) = if grid {
("entries-grid", "tile", "tile-icon")
} else {
("entries-list", "row", "row-icon")
};
view! {
<div class="entries-grid">
<div class=container>
// Only the visible page is rendered; the click handlers still
// index into the whole listing, so Shift+click ranges reach
// past it. Keyed by index: within one listing the order is
// fixed, so revealing a page only appends tiles.
// fixed, so revealing a page only appends items.
<For
each=move || 0..shown.get().min(total)
key=|i: &usize| *i
@@ -742,77 +736,17 @@ fn grid_view(
let icon = icon_for(e.kind, &e.name);
let name = e.name.clone();
let title = name.clone();
let (open_cb, on_click, on_ctx) = entry_callbacks(
all.clone(), i, root_id, loc, is_rw, single_click,
selected, set_selected, set_ctx, open_file,
);
let tile_name = name.clone();
let selected_now = move || sel_names.with(|s| s.contains(&tile_name));
view! {
<div
class="tile"
class:selected=selected_now
tabindex=0
on:click=on_click
on:dblclick=move |ev: MouseEvent| if plain_dblclick(&ev) { open_cb.run(()) }
on:keydown=move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Enter" || ev.key() == " " {
ev.prevent_default();
open_cb.run(());
}
}
on:contextmenu=on_ctx
title=title
>
// `icon_svg`, not `<Icon/>`: no reactive owner
// per tile (see its doc).
{icon_svg(icon, "tile-icon")}
<div class="tile-name">{name}</div>
</div>
}
}
/>
</div>
}
}
#[allow(clippy::too_many_arguments)] // explicit signal props
fn list_view(
all: Arc<Listing>,
shown: RwSignal<usize>,
root_id: i64,
loc: ReadSignal<Location>,
is_rw: bool,
single_click: bool,
selected: ReadSignal<Vec<Entry>>,
sel_names: Memo<HashSet<String>>,
set_selected: WriteSignal<Vec<Entry>>,
set_ctx: WriteSignal<Option<CtxMenu>>,
open_file: WriteSignal<Option<FileView>>,
) -> impl IntoView {
let total = all.entries.len();
view! {
<div class="entries-list">
<For
each=move || 0..shown.get().min(total)
key=|i: &usize| *i
children=move |i: usize| {
let e = &all.entries[i];
let icon = icon_for(e.kind, &e.name);
let name = e.name.clone();
let size = format_size(e.size);
let size = if e.is_dir { "—".to_string() } else { format_size(e.size) };
let date = format_date(&e.mtime);
let is_dir = e.is_dir;
let title = name.clone();
let (open_cb, on_click, on_ctx) = entry_callbacks(
all.clone(), i, root_id, loc, is_rw, single_click,
selected, set_selected, set_ctx, open_file,
);
let row_name = name.clone();
let selected_now = move || sel_names.with(|s| s.contains(&row_name));
let item_name = name.clone();
let selected_now = move || sel_names.with(|s| s.contains(&item_name));
view! {
<div
class="row"
class=item
class:selected=selected_now
tabindex=0
on:click=on_click
@@ -826,10 +760,18 @@ fn list_view(
on:contextmenu=on_ctx
title=title
>
{icon_svg(icon, "row-icon")}
<span class="row-name">{name}</span>
<span class="row-size muted">{if is_dir { "—".to_string() } else { size }}</span>
<span class="row-date muted">{date}</span>
// `icon_svg`, not `<Icon/>`: no reactive owner
// per item (see its doc).
{icon_svg(icon, icon_class)}
{if grid {
view! { <div class="tile-name">{name}</div> }.into_any()
} else {
view! {
<span class="row-name">{name}</span>
<span class="row-size muted">{size}</span>
<span class="row-date muted">{date}</span>
}.into_any()
}}
</div>
}
}
▾Mweb/src/views/shares.rs
@@ -11,6 +11,7 @@ use crate::i18n;
use crate::icons::IconName;
use crate::router::share_url;
use crate::util::format_date;
use crate::util::select_value;
// ---------------------------------------------------------------------------
// Expiry helpers (compute the RFC 3339 expiry client-side)
@@ -67,12 +68,6 @@ fn copy_to_clipboard(text: &str, toast: ToastMsg) {
});
}
fn select_value(ev: &web_sys::Event) -> Option<String> {
ev.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
.map(|s| s.value())
}
// ---------------------------------------------------------------------------
// Share creation dialog (two-phase: form -> link)
// ---------------------------------------------------------------------------