M9: online files: the app remembers the files it put online
A file put on a server without the server's key stays its owner's after its tab closes or the app quits. The app records it with the preferences (server, id, name, owner token, the owner's links, what others join with; at most 200; validated when read back) and never shows the token: not in command results, ui.inspect, logs or debug output. - Home > Online lists your online files: open one as its owner (a new tab, or its tab comes to the front), start and stop sharing, copy the code or link, rename on the server, remove from the server (confirmed), forget here. One request per server when the section opens; offline and gone states. - collab.files, collab.openFile, collab.forgetFile; collab.manageFile takes its server and token from the record and gains share / unshare. - Server: Admin owned (a compatible addition within protocol version 2) says what became of the files a list of owner tokens owns. Older servers leave the list as recorded. - Tabs mark files in a live session (shared, or open only to you); toasts about a file that isn't in front name it; after a crash a recorded file reopens from its server as its owner. - Draft catalogs (de, fr, ja, ko, pt-BR, zh-Hans) translate the 71 strings and 5 command labels they lacked and the new ones; es and es-419 follow. - Docs: collab-protocol, server, mcp, control-protocol, parity pages, gaps, ROADMAP.
This commit is contained in:
parent
517b1a02ce
commit
501e4a6737
21
ROADMAP.md
21
ROADMAP.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
|
|
@ -100,15 +100,15 @@ native speakers' reviews of the six language drafts need people.
|
|||
|---|---|---|---|
|
||||
| English | en | 100% | full |
|
||||
| Simplified Chinese | zh-Hans | 100% | draft (needs native review) |
|
||||
| Spanish | es, es-419 | 100% (es-419: an overlay of 87 entries) | draft (needs native review) |
|
||||
| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) |
|
||||
| Hindi | hi | 0% | none |
|
||||
| Arabic | ar | 0% | none |
|
||||
| French | fr | 99% | draft (needs native review) |
|
||||
| Portuguese (Brazil) | pt-BR | 99% | draft (needs native review) |
|
||||
| French | fr | 100% | draft (needs native review) |
|
||||
| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) |
|
||||
| Indonesian | id | 0% | none |
|
||||
| Japanese | ja | 99% | draft (needs native review) |
|
||||
| German | de | 99% | draft (needs native review) |
|
||||
| Korean | ko | 99% | draft (needs native review) |
|
||||
| Japanese | ja | 100% | draft (needs native review) |
|
||||
| German | de | 100% | draft (needs native review) |
|
||||
| Korean | ko | 100% | draft (needs native review) |
|
||||
| Vietnamese | vi | 0% | none |
|
||||
|
||||
No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
|
|
@ -131,6 +131,12 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-12** · Your online files: a file shared or published online stays yours after its tab closes or the app
|
||||
quits. The app remembers it (with its owner token, kept with the preferences and never shown) and Home ▸ **Online**
|
||||
lists what you own: open a file again as its owner, start or stop sharing, copy its code, rename it, remove it from
|
||||
the server or forget it here. Servers are asked once, when the list opens; tabs mark files in a live session; a
|
||||
crash reopens such a file from its server. `collab.files`, `collab.openFile`, `collab.forgetFile`; servers need an
|
||||
upgrade only to tell which files are still there. The six draft catalogs translate every string again
|
||||
- **2026-10-12** · Everyday editing partials: number fields (scrub rates with Shift and Alt, arrow keys by the nudge
|
||||
preferences, arithmetic with brackets, relative edits of Mixed values per layer), outline mode's hidden layers and
|
||||
object bounds, Hide when publishing in the Assets panel, a cursor for every tool and modifier, panel widths that are
|
||||
|
|
@ -280,6 +286,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner. `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. The draft catalogs (French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese) translate the 71 strings and 5 command labels they lacked and the new ones. Numbers unchanged |
|
||||
| 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains |
|
||||
| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Numbers unchanged |
|
||||
| 2026-10-12 | minor | Live sessions per document (M9, a fix from real use): the engine keeps one session per open document instead of one for the app, the link carries several under their own ids (desktop, browser, tests), the window and the `collab.*` commands follow the active document, `collab.peers` gains `session` and `sessions`, `ui.inspect`'s `live` gains `session` and `sessions`; joining opens the session's file in its own tab without touching other sessions and closing a file ends its session only. Numbers unchanged |
|
||||
|
|
|
|||
|
|
@ -99,6 +99,8 @@ mod hardening_tests;
|
|||
#[cfg(test)]
|
||||
mod library_tests;
|
||||
#[cfg(test)]
|
||||
mod owned_tests;
|
||||
#[cfg(test)]
|
||||
mod public_tests;
|
||||
#[cfg(test)]
|
||||
mod server_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
//! Your online files against a real server: a file shared online is recorded with its owner
|
||||
//! token; after a restart of the app the server says what became of it (`Admin::Owned`, one
|
||||
//! request for all the files), it opens again as its owner in step with everyone who joined by
|
||||
//! code, its sharing stops and starts from the record, and removing it takes it off the server's
|
||||
//! disk. The request itself: only a file's own token learns about it, junk never hurts, and an
|
||||
//! owner's question opens nothing (a file isn't kept alive by being asked about).
|
||||
|
||||
use figcraft_collab::message::{Admin, AdminReply, ClientMsg, MAX_OWNED, OpenHosting, PROTOCOL, ServerMsg};
|
||||
use figcraft_engine::Prefs;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::test_util::{Raw, TempDir, config, live, pump, same, session};
|
||||
|
||||
fn open(dir: &std::path::Path) -> Config {
|
||||
Config { open_publish: true, ..config(dir) }
|
||||
}
|
||||
|
||||
fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
/// An open hosting connection that names no file.
|
||||
fn hosting(addr: std::net::SocketAddr) -> Raw {
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: "test".into(), file: String::new(), owner: String::new() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
|
||||
raw
|
||||
}
|
||||
|
||||
fn owned(raw: &mut Raw, files: Vec<(String, String)>) -> AdminReply {
|
||||
raw.send(&ClientMsg::Admin(Admin::Owned { files }));
|
||||
match raw.next() {
|
||||
ServerMsg::Admin(r) => *r,
|
||||
other => panic!("answered {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ada shares her open file online through the server at `url`: the app with the file live.
|
||||
fn share(url: &str) -> figcraft_engine::Session {
|
||||
let mut a = session("Ada");
|
||||
ex(&mut a, "pref.publishServer", json!({"url": url}));
|
||||
ex(&mut a, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30}));
|
||||
ex(&mut a, "collab.share", json!({}));
|
||||
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().share.as_ref().is_some_and(|sh| sh.links.is_some()));
|
||||
a
|
||||
}
|
||||
|
||||
/// The app quits (its sessions end with it); the next run has only the preferences.
|
||||
fn restart(a: figcraft_engine::Session, name: &str) -> figcraft_engine::Session {
|
||||
let prefs: Prefs = serde_json::from_value(serde_json::to_value(&a.prefs).unwrap()).unwrap();
|
||||
drop(a);
|
||||
let mut next = session(name);
|
||||
next.prefs = prefs;
|
||||
next
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_file_is_its_owners_again_after_the_app_restarts() {
|
||||
let dir = TempDir::new("owned-restart");
|
||||
let mut server = crate::start(&open(dir.path())).unwrap();
|
||||
let url = format!("ws://{}/", server.addr());
|
||||
let a = share(&url);
|
||||
let code = a.live_summary()["code"].as_str().unwrap().to_string();
|
||||
let file = a.prefs.online_files[0].file.clone();
|
||||
let owner = a.prefs.online_files[0].owner.clone();
|
||||
assert_eq!(a.live().published.as_ref().and_then(|p| p.owner.clone()), Some(owner.clone()));
|
||||
// The app quits. The next run knows the file from its preferences alone.
|
||||
let mut a = restart(a, "Ada");
|
||||
let list = ex(&mut a, "collab.files", json!({}));
|
||||
assert_eq!((&list["files"][0]["file"], &list["files"][0]["state"], &list["files"][0]["code"]), (&json!(file), &json!("unknown"), &json!(code)));
|
||||
// The server says it is there, and still shared.
|
||||
let list = ex(&mut a, "collab.files", json!({"refresh": true}));
|
||||
assert_eq!((&list["files"][0]["state"], &list["files"][0]["shared"], &list["problems"]), (&json!("online"), &json!(true), &json!([])));
|
||||
assert!(list["files"][0]["expires"].as_u64().is_some_and(|e| e > crate::store::now()));
|
||||
assert!(!list.to_string().contains(&owner));
|
||||
// Open it: the owner is in the file, synced, in a tab of its own.
|
||||
let opened = ex(&mut a, "collab.openFile", json!({"file": file}));
|
||||
assert!(!opened.to_string().contains(&owner));
|
||||
pump(&mut [&mut a], "the owner back in", |s| live(s[0]));
|
||||
assert_eq!(a.docs.len(), 2);
|
||||
assert!(!a.live_summary().to_string().contains(&owner), "the token stays in the record");
|
||||
// Someone joins with the code given out before the restart, and sees the owner's edits.
|
||||
let mut b = session("Bo");
|
||||
ex(&mut b, "pref.publishServer", json!({"url": url}));
|
||||
ex(&mut b, "collab.join", json!({"code": code}));
|
||||
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
||||
ex(&mut a, "shape.ellipse", json!({"x": 100, "y": 100, "width": 20, "height": 20}));
|
||||
pump(&mut [&mut a, &mut b], "the edit at Bo's", |s| same(s[0].doc().unwrap(), s[1].doc().unwrap()));
|
||||
// Stop sharing from the list (no tab needed): Bo is out, the owner stays, the code is dead.
|
||||
assert_eq!(ex(&mut a, "collab.manageFile", json!({"file": file, "action": "unshare"}))["shared"], false);
|
||||
pump(&mut [&mut a, &mut b], "Bo out", |s| !s[1].live().status.active() && live(s[0]));
|
||||
assert_eq!(a.live_summary()["share"]["shared"], false, "the open tab follows");
|
||||
let mut c = session("Cy");
|
||||
ex(&mut c, "pref.publishServer", json!({"url": url}));
|
||||
ex(&mut c, "collab.join", json!({"code": code}));
|
||||
pump(&mut [&mut c], "the old code refused", |s| matches!(s[0].live().status, figcraft_engine::live::Status::Failed { .. }));
|
||||
// Start again: a new code for the same file.
|
||||
let again = ex(&mut a, "collab.share", json!({}));
|
||||
let new_code = again["code"].as_str().unwrap().to_string();
|
||||
assert_ne!(new_code, code);
|
||||
assert_eq!(ex(&mut a, "collab.files", json!({}))["files"][0]["code"], new_code);
|
||||
let mut d = session("Di");
|
||||
ex(&mut d, "pref.publishServer", json!({"url": url}));
|
||||
ex(&mut d, "collab.join", json!({"code": new_code}));
|
||||
pump(&mut [&mut a, &mut d], "Di in with the new code", |s| live(s[1]));
|
||||
// Rename, then remove: the file leaves the server's disk and the list; the tab's session ends.
|
||||
ex(&mut a, "collab.manageFile", json!({"file": file, "action": "rename", "name": "Landing"}));
|
||||
assert_eq!(ex(&mut a, "collab.files", json!({"refresh": true}))["files"][0]["name"], "Landing");
|
||||
assert!(dir.path().join("files").join(&file).exists());
|
||||
assert_eq!(ex(&mut a, "collab.manageFile", json!({"file": file, "action": "remove"}))["done"], true);
|
||||
assert!(!dir.path().join("files").join(&file).exists(), "gone from the data directory");
|
||||
assert!(a.prefs.online_files.is_empty() && !a.live().status.active() && a.docs.len() == 2, "the copy stays open");
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_expired_or_was_removed_elsewhere_shows_as_gone() {
|
||||
let dir = TempDir::new("owned-gone");
|
||||
let cfg = open(dir.path());
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let url = format!("ws://{}/", server.addr());
|
||||
let mut a = share(&url);
|
||||
let file = a.prefs.online_files[0].file.clone();
|
||||
ex(&mut a, "collab.leave", json!({}));
|
||||
let mut a = restart(a, "Ada");
|
||||
// The operator removes it.
|
||||
crate::cli::run(&crate::cli::Cli { config: cfg.clone(), command: crate::cli::Command::RemoveFile { file: file.clone() } }).unwrap();
|
||||
let list = ex(&mut a, "collab.files", json!({"refresh": true}));
|
||||
assert_eq!((&list["files"][0]["state"], &list["files"][0]["shared"]), (&json!("gone"), &json!(false)));
|
||||
assert!(a.execute("collab.openFile", &json!({"file": file})).unwrap_err().to_string().contains("no longer on its server"));
|
||||
assert_eq!(ex(&mut a, "collab.forgetFile", json!({"file": file}))["onServer"], false);
|
||||
// The server stops: the list says it couldn't be reached and keeps what it has.
|
||||
let mut b = share(&url);
|
||||
ex(&mut b, "collab.leave", json!({}));
|
||||
server.stop();
|
||||
let list = ex(&mut b, "collab.files", json!({"refresh": true}));
|
||||
assert_eq!((&list["files"][0]["state"], &list["problems"][0]["state"]), (&json!("online"), &json!("offline")));
|
||||
assert_eq!(list["files"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_files_own_token_learns_about_it() {
|
||||
let dir = TempDir::new("owned-ask");
|
||||
let cfg = open(dir.path());
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
let a = share(&format!("ws://{addr}/"));
|
||||
let (file, owner) = (a.prefs.online_files[0].file.clone(), a.prefs.online_files[0].owner.clone());
|
||||
let opened_before = std::fs::read_to_string(dir.path().join("files").join(&file).join("meta.json")).unwrap();
|
||||
let mut raw = hosting(addr);
|
||||
let pair = |f: &str, o: &str| (f.to_string(), o.to_string());
|
||||
let reply = owned(
|
||||
&mut raw,
|
||||
vec![pair(&file, &owner), pair(&file, "WrongTokenWrongToken00"), pair("nosuchfile", &owner), pair("", ""), pair("../../etc", "x")],
|
||||
);
|
||||
let AdminReply::Owned(list) = reply else { panic!("answered {reply:?}") };
|
||||
assert_eq!(list.len(), 5, "one answer per pair, in order");
|
||||
let first = list[0].as_ref().expect("its own token");
|
||||
assert_eq!(first.name, "Untitled");
|
||||
assert_eq!(first.links.len(), 4, "the owner's two links and the two made for others");
|
||||
assert!(first.expires.is_some());
|
||||
assert!(list[1..].iter().all(Option::is_none), "a wrong token and a missing file read the same");
|
||||
// Nothing asked: nothing answered; too many: refused, and the connection stays usable.
|
||||
assert_eq!(owned(&mut raw, vec![]), AdminReply::Owned(vec![]));
|
||||
let many = vec![pair(&file, &owner); MAX_OWNED + 1];
|
||||
assert!(matches!(owned(&mut raw, many), AdminReply::Failed { reason } if reason.contains("at most")));
|
||||
assert!(matches!(owned(&mut raw, vec![pair(&file, &owner); MAX_OWNED]), AdminReply::Owned(l) if l.len() == MAX_OWNED));
|
||||
// Junk ids and tokens (long, control characters, other scripts) are just not theirs.
|
||||
let junk = vec![pair(&"x".repeat(100_000), &"y".repeat(100_000)), pair("\u{0}\u{7}", "\u{1F4A5}"), pair(&file, &format!("{owner}\u{0}"))];
|
||||
assert!(matches!(owned(&mut raw, junk), AdminReply::Owned(l) if l.iter().all(Option::is_none)));
|
||||
// On that connection the token still manages nothing: it named no file when it opened.
|
||||
raw.send(&ClientMsg::Admin(Admin::Remove { file: file.clone() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { .. })));
|
||||
assert!(dir.path().join("files").join(&file).exists());
|
||||
// Asking opened nothing: when the file was last opened is as it was.
|
||||
assert_eq!(std::fs::read_to_string(dir.path().join("files").join(&file).join("meta.json")).unwrap(), opened_before);
|
||||
// A connection that manages one file with its token may ask too; the server key as well.
|
||||
let mut one = Raw::connect(addr);
|
||||
one.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: "test".into(), file: file.clone(), owner: owner.clone() }));
|
||||
assert!(matches!(one.next(), ServerMsg::Hosting(_)));
|
||||
assert!(matches!(owned(&mut one, vec![pair(&file, &owner)]), AdminReply::Owned(l) if l[0].is_some()));
|
||||
drop(a);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_isnt_open_to_the_public_answers_that_nothing_is_owned() {
|
||||
let dir = TempDir::new("owned-private");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let mut raw = hosting(server.addr());
|
||||
assert!(matches!(owned(&mut raw, vec![("f".into(), "t".into())]), AdminReply::Owned(l) if l == [None]));
|
||||
server.stop();
|
||||
}
|
||||
|
|
@ -98,9 +98,9 @@ fn anyone_publishes_and_the_owner_token_manages_the_file() {
|
|||
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("only its own"))));
|
||||
raw.send(&ClientMsg::Admin(Admin::Listing));
|
||||
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("server key"))));
|
||||
// Removing it disconnects everyone in it.
|
||||
// Removing it disconnects everyone in it (whoever removed it just leaves: no failure there).
|
||||
assert_eq!(manage(&mut a, json!({"action": "remove"})).unwrap()["done"], true);
|
||||
pump(&mut [&mut a, &mut b], "everyone told", |s| s.iter().all(|x| x.live_summary()["status"] == "failed"));
|
||||
pump(&mut [&mut a, &mut b], "everyone told", |s| s[0].live_summary()["status"] == "off" && s[1].live_summary()["status"] == "failed");
|
||||
assert!(b.message.as_deref().unwrap_or_default().contains("removed"), "{:?}", b.message);
|
||||
assert!(!dir.path().join("files").join(&p.file).exists());
|
||||
server.stop();
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ use std::path::{Path, PathBuf};
|
|||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use figcraft_collab::message::{
|
||||
Access, Admin, AdminReply, ClientMsg, FileInfo, FileRow, FileUsage, HostInfo, LibraryReply, LibraryReq, LinkInfo, NewLink, PluginReply,
|
||||
PluginReq, ReportInfo, Usage,
|
||||
Access, Admin, AdminReply, ClientMsg, FileInfo, FileRow, FileUsage, HostInfo, LibraryReply, LibraryReq, LinkInfo, MAX_OWNED, NewLink, OwnedFile,
|
||||
PluginReply, PluginReq, ReportInfo, Usage,
|
||||
};
|
||||
use figcraft_collab::{Change, Replica, Server, same_secret};
|
||||
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_key};
|
||||
|
|
@ -621,6 +621,26 @@ impl Store {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Owner token `owner` manages file `file` (one published without the key).
|
||||
fn owns(&self, file: &str, owner: &str) -> bool {
|
||||
let presented = hash(owner);
|
||||
self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h))
|
||||
}
|
||||
|
||||
/// What became of the files these `(file id, owner token)` pairs own: each one's name, links
|
||||
/// and expiry, or `None` when that token manages no file of that id here (which doesn't say
|
||||
/// whether the id exists). Asking opens nothing: it doesn't count as using a file.
|
||||
pub fn owned(&self, files: &[(String, String)]) -> AdminReply {
|
||||
if files.len() > MAX_OWNED {
|
||||
return AdminReply::Failed { reason: format!("ask about at most {MAX_OWNED} files at a time") };
|
||||
}
|
||||
let one = |(file, owner): &(String, String)| {
|
||||
let e = self.files.get(file).filter(|_| self.owns(file, owner))?;
|
||||
Some(OwnedFile { name: e.meta.name.clone(), links: e.links.iter().map(|l| l.id.clone()).collect(), expires: self.expires(&e.meta) })
|
||||
};
|
||||
AdminReply::Owned(files.iter().map(one).collect())
|
||||
}
|
||||
|
||||
/// Rename a file (its listing; the document inside keeps its own name).
|
||||
pub fn rename(&mut self, file: &str, name: &str) -> Result<(), String> {
|
||||
let name = clean_name(name);
|
||||
|
|
@ -1342,9 +1362,7 @@ impl Backend for Store {
|
|||
if file.is_empty() && owner.is_empty() {
|
||||
return Ok(self.host_info(None));
|
||||
}
|
||||
let presented = hash(owner);
|
||||
let owns = self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h));
|
||||
if !owns {
|
||||
if !self.owns(file, owner) {
|
||||
return Err("that owner token doesn't manage a file of that id here (it may have been removed or expired)".into());
|
||||
}
|
||||
Ok(self.host_info(Some(file)))
|
||||
|
|
@ -1365,6 +1383,7 @@ impl Backend for Store {
|
|||
}
|
||||
};
|
||||
}
|
||||
Admin::Owned { files } => return self.owned(files).into(),
|
||||
Admin::Links { file }
|
||||
| Admin::CreateLink { file, .. }
|
||||
| Admin::Revoke { file, .. }
|
||||
|
|
@ -1407,6 +1426,7 @@ impl Backend for Store {
|
|||
Admin::Unblock { ip } => self.block(&ip, false).map_or_else(failed, |_| AdminReply::Done.into()),
|
||||
Admin::Blocked => AdminReply::Blocked(self.blocked()).into(),
|
||||
Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()),
|
||||
Admin::Owned { files } => self.owned(&files).into(),
|
||||
Admin::Remove { file } => {
|
||||
if !self.files.contains_key(&file) {
|
||||
return failed("this file isn't on the server (it may have been removed)".into());
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ mod services;
|
|||
#[cfg(test)]
|
||||
mod args_tests;
|
||||
#[cfg(test)]
|
||||
mod recovery_online_tests;
|
||||
#[cfg(test)]
|
||||
mod recovery_tests;
|
||||
#[cfg(test)]
|
||||
mod services_tests;
|
||||
|
|
@ -129,12 +131,14 @@ fn main() -> eframe::Result {
|
|||
}
|
||||
// Documents a crashed or killed run left unsaved come back; this run keeps its own copies.
|
||||
let recovery_root = services::config_dir().map(|d| d.join("Recovery"));
|
||||
let recovered = recovery_root.as_deref().map_or(0, |r| recovery::recover(r, &mut session));
|
||||
let recovery: recovery::Shared = Arc::new(Mutex::new(recovery_root.as_deref().and_then(recovery::Recovery::start)));
|
||||
// The preferences first: they hold the online files a recovered document may be one of.
|
||||
let recovery = recovery::Shared::default();
|
||||
let svc = services::Desktop::load(recovery.clone());
|
||||
if let Some(prefs) = svc.prefs.clone() {
|
||||
session.prefs = prefs;
|
||||
}
|
||||
let recovered = recovery_root.as_deref().map_or(0, |r| recovery::recover(r, &mut session));
|
||||
*recovery::lock(&recovery) = recovery_root.as_deref().and_then(recovery::Recovery::start);
|
||||
// Plugins installed from a server's plugin directory live with the user's settings.
|
||||
session.plugins.install_dir = services::config_dir().map(|d| d.join("Plugins"));
|
||||
// Invite links open this app (Windows registers per user at run time; installed builds by
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@
|
|||
//! once the document is saved or closed. The app holds a lock on its folder. At startup, the folders
|
||||
//! whose lock is free belong to runs that ended without closing: their copies open again as
|
||||
//! unsaved documents (`file.recover`) and the folder goes. Closing the app normally removes its own.
|
||||
//!
|
||||
//! A document that is one of your online files, in step with its server, has a note beside its
|
||||
//! copy saying which (`<uid>.online`: the server and the file's id, never a token): the next start
|
||||
//! opens that file again as its owner (`collab.openFile`) in place of the stale copy. Without
|
||||
//! the note, or when the file is no longer recorded, the copy opens as any other.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
|
|
@ -64,12 +69,19 @@ impl Recovery {
|
|||
pub fn write(&mut self, s: &Session, wait: bool) {
|
||||
for d in &s.docs {
|
||||
let path = self.dir.join(format!("{}.figcraft", d.uid));
|
||||
let note = path.with_extension(ONLINE);
|
||||
if !needs_copy(d) {
|
||||
if self.written.remove(&d.uid).is_some() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(¬e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Which online file it is (a line each: the server, the file's id), while it is one.
|
||||
let _ = match s.online_of(d.uid) {
|
||||
Some((server, file)) => std::fs::write(¬e, format!("{server}\n{file}")),
|
||||
None => std::fs::remove_file(¬e),
|
||||
};
|
||||
let entry = self.written.entry(d.uid).or_insert((0, None));
|
||||
let busy = entry.1.as_ref().is_some_and(|h| !h.is_finished());
|
||||
if entry.0 == d.revision || busy {
|
||||
|
|
@ -92,6 +104,7 @@ impl Recovery {
|
|||
let keep = open.contains(uid);
|
||||
if !keep {
|
||||
let _ = std::fs::remove_file(dir.join(format!("{uid}.figcraft")));
|
||||
let _ = std::fs::remove_file(dir.join(format!("{uid}.{ONLINE}")));
|
||||
}
|
||||
keep
|
||||
});
|
||||
|
|
@ -109,6 +122,18 @@ impl Recovery {
|
|||
}
|
||||
}
|
||||
|
||||
/// The extension of the note beside the copy of a document that is one of your online files.
|
||||
const ONLINE: &str = "online";
|
||||
|
||||
/// The copy at `file` is of one of your online files that was in step with its server: open that
|
||||
/// file again as its owner. False when it wasn't one, or can't be opened that way now.
|
||||
fn reopen_online(file: &Path, s: &mut Session) -> bool {
|
||||
let Ok(note) = std::fs::read_to_string(file.with_extension(ONLINE)) else { return false };
|
||||
let mut lines = note.lines().map(str::trim);
|
||||
let (Some(server), Some(id)) = (lines.next(), lines.next()) else { return false };
|
||||
s.execute("collab.openFile", &json!({"server": server, "file": id})).is_ok()
|
||||
}
|
||||
|
||||
/// A document whose changes would be lost (a branch tab's edits are in its main file).
|
||||
fn needs_copy(d: &figcraft_engine::DocState) -> bool {
|
||||
d.is_dirty() && d.branch_of.is_none()
|
||||
|
|
@ -124,13 +149,19 @@ fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document) {
|
|||
|
||||
/// Open the copies left by runs that ended without closing (folders under `root` whose lock is
|
||||
/// free, and copies directly in `root` from older versions) as unsaved documents, then remove them.
|
||||
/// A copy that can't be read stays where it is. Returns how many documents came back.
|
||||
/// A copy that can't be read stays where it is. Returns how many documents came back as unsaved
|
||||
/// copies (your online files open again from their servers instead and aren't counted).
|
||||
pub fn recover(root: &Path, s: &mut Session) -> usize {
|
||||
let Ok(entries) = std::fs::read_dir(root) else { return 0 };
|
||||
let mut n = 0;
|
||||
let mut open = |file: &Path, s: &mut Session| {
|
||||
if file.extension().is_some_and(|e| e == "figcraft") && s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
|
||||
n += 1;
|
||||
if file.extension().is_none_or(|e| e != "figcraft") {
|
||||
return;
|
||||
}
|
||||
// One of your online files comes back from its server; anything else from its copy.
|
||||
let online = reopen_online(file, s);
|
||||
if online || s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
|
||||
n += usize::from(!online);
|
||||
let _ = std::fs::remove_file(file);
|
||||
}
|
||||
};
|
||||
|
|
@ -154,6 +185,10 @@ pub fn recover(root: &Path, s: &mut Session) -> usize {
|
|||
open(f, s);
|
||||
}
|
||||
}
|
||||
// The notes go with their copies (one whose copy couldn't be read stays with it).
|
||||
for f in files.iter().filter(|f| f.extension().is_some_and(|e| e == ONLINE) && !f.with_extension("figcraft").exists()) {
|
||||
let _ = std::fs::remove_file(f);
|
||||
}
|
||||
drop(lock);
|
||||
// Empty now unless a copy couldn't be read (kept for the next start).
|
||||
let _ = std::fs::remove_file(path.join(LOCK));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
//! Recovery of your online files: a document that was one of them, in step with its server, when
|
||||
//! a run ended without closing opens again from the server as its owner (`collab.openFile`), not
|
||||
//! as a stale unsaved copy; the note beside its copy names the server and file and never a token;
|
||||
//! and without the record the copy opens as any other.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::figcraft_collab::message::{ClientMsg, ServerMsg};
|
||||
use figcraft_engine::figcraft_collab::{Client, Limits, Outgoing, Server};
|
||||
use figcraft_engine::live::{Access, Event, HostOptions, LiveLink, Presence, PublishOptions, Published, Role, SessionInfo};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::recovery::{Recovery, recover};
|
||||
|
||||
const SERVER: &str = "wss://share.example/";
|
||||
const OWNER: &str = "OwnerSecretOwnerSecret";
|
||||
const EDIT: &str = "figcraft://join?server=wss://share.example/&file=f1&token=EditSecretEditSecret00";
|
||||
|
||||
/// The joins asked for, and the events handed out next (as the session started last).
|
||||
#[derive(Default)]
|
||||
struct Script {
|
||||
joined: Vec<String>,
|
||||
events: Vec<Event>,
|
||||
session: u64,
|
||||
}
|
||||
|
||||
struct Link(Arc<Mutex<Script>>);
|
||||
|
||||
impl LiveLink for Link {
|
||||
fn host(&mut self, _: u64, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
s.joined.push(link.to_string());
|
||||
Ok(())
|
||||
}
|
||||
fn publish(&mut self, session: u64, _: PublishOptions) -> Result<(), String> {
|
||||
self.0.lock().unwrap().session = session;
|
||||
Ok(())
|
||||
}
|
||||
fn leave(&mut self, _: u64) {}
|
||||
fn presence(&mut self, _: u64, _: &Presence) {}
|
||||
fn send(&mut self, _: u64, _: ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<(u64, Event)> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
let session = s.session;
|
||||
std::mem::take(&mut s.events).into_iter().map(|e| (session, e)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn session() -> (Session, Arc<Mutex<Script>>) {
|
||||
let mut s = Session::new();
|
||||
let script = Arc::new(Mutex::new(Script::default()));
|
||||
s.live.install(Box::new(Link(script.clone())));
|
||||
(s, script)
|
||||
}
|
||||
|
||||
fn root(name: &str) -> std::path::PathBuf {
|
||||
let r = std::env::temp_dir().join(format!("figcraft-recovery-online-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&r);
|
||||
r
|
||||
}
|
||||
|
||||
/// A run whose open file was shared online and is in step with the server, then edited.
|
||||
fn shared_and_edited() -> Session {
|
||||
let (mut s, script) = session();
|
||||
s.execute("pref.publishServer", &json!({"url": SERVER})).unwrap();
|
||||
s.execute("collab.share", &json!({})).unwrap();
|
||||
let published = Published {
|
||||
server: "share.example".into(),
|
||||
file: "f1".into(),
|
||||
name: "Homepage".into(),
|
||||
edit: EDIT.into(),
|
||||
view: String::new(),
|
||||
owner: Some(OWNER.into()),
|
||||
expires: None,
|
||||
};
|
||||
let info = SessionInfo {
|
||||
role: Role::Guest,
|
||||
me: 1,
|
||||
name: "Me".into(),
|
||||
color: "#0D99FF".into(),
|
||||
file_name: "Homepage".into(),
|
||||
pages: vec![],
|
||||
link: EDIT.into(),
|
||||
url: String::new(),
|
||||
address: SERVER.into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
};
|
||||
let mut server = Server::new(s.doc().unwrap().clone(), "", Limits::default());
|
||||
let ClientMsg::Hello(me) = Client::hello("f1", "", "Me", "") else { panic!("no hello") };
|
||||
let (_, outs) = server.join(me).unwrap();
|
||||
let welcome = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap();
|
||||
script.lock().unwrap().events.extend([Event::Published(published), Event::Welcome { info, peers: vec![] }, Event::Sync(welcome)]);
|
||||
s.live_poll();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
assert!(s.docs[0].is_dirty() && s.online_of(s.docs[0].uid).is_some());
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_online_file_comes_back_from_its_server_not_from_the_stale_copy() {
|
||||
let root = root("reopen");
|
||||
let crashed = shared_and_edited();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
r.write(&crashed, true);
|
||||
let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path();
|
||||
let note = std::fs::read_to_string(dir.join(format!("{}.online", crashed.docs[0].uid))).unwrap();
|
||||
assert_eq!(note, format!("{SERVER}\nf1"), "which file it is");
|
||||
assert!(!note.contains(OWNER) && !note.contains("token="), "never a secret");
|
||||
// The run ends without closing; the next one has the preferences (with the record).
|
||||
drop(r);
|
||||
let (mut next, script) = session();
|
||||
next.docs.clear();
|
||||
next.prefs = serde_json::from_value(serde_json::to_value(&crashed.prefs).unwrap()).unwrap();
|
||||
assert_eq!(recover(&root, &mut next), 0, "nothing comes back as an unsaved copy");
|
||||
assert_eq!(script.lock().unwrap().joined, [EDIT], "it is joined again as its owner");
|
||||
assert!(next.docs.is_empty(), "its tab opens when the server's copy arrives");
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone, note and all");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_its_record_the_copy_opens_as_any_other() {
|
||||
let root = root("fallback");
|
||||
let crashed = shared_and_edited();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
r.write(&crashed, true);
|
||||
drop(r);
|
||||
// The record was forgotten (or the preferences lost): the copy is what there is.
|
||||
let (mut next, script) = session();
|
||||
next.docs.clear();
|
||||
assert_eq!(recover(&root, &mut next), 1);
|
||||
assert!(script.lock().unwrap().joined.is_empty());
|
||||
assert!(next.docs.first().is_some_and(|d| d.is_dirty() && d.path.is_none()));
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_note_goes_when_the_file_leaves_its_session_or_is_closed() {
|
||||
let root = root("note");
|
||||
let mut s = shared_and_edited();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
r.write(&s, true);
|
||||
let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path();
|
||||
let notes = |d: &std::path::Path| std::fs::read_dir(d).unwrap().flatten().filter(|f| f.path().extension().is_some_and(|e| e == "online")).count();
|
||||
assert_eq!(notes(&dir), 1);
|
||||
// Left: the copy is an ordinary one from the next write on.
|
||||
s.execute("collab.leave", &json!({})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
r.write(&s, true);
|
||||
assert_eq!(notes(&dir), 0);
|
||||
r.finish();
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_none());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
|
@ -138,6 +138,8 @@ mod merge_list_tests;
|
|||
#[cfg(test)]
|
||||
mod merge_tests;
|
||||
#[cfg(test)]
|
||||
mod owned_tests;
|
||||
#[cfg(test)]
|
||||
mod reconnect_tests;
|
||||
#[cfg(test)]
|
||||
mod server_tests;
|
||||
|
|
|
|||
|
|
@ -270,6 +270,24 @@ pub enum Admin {
|
|||
/// the server give it to whoever types the code): [`AdminReply::Code`]. A link has one code:
|
||||
/// asking again replaces it. Servers older than join codes can't read this request.
|
||||
Code { file: String, token: String },
|
||||
/// On an open hosting connection (no file named in its opening): what became of the files
|
||||
/// these `(file id, owner token)` pairs own, at most [`MAX_OWNED`] of them:
|
||||
/// [`AdminReply::Owned`]. Servers older than this request can't read it.
|
||||
Owned { files: Vec<(String, String)> },
|
||||
}
|
||||
|
||||
/// The most files one [`Admin::Owned`] asks about.
|
||||
pub const MAX_OWNED: usize = 200;
|
||||
|
||||
/// A file its owner asked about ([`Admin::Owned`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OwnedFile {
|
||||
/// Its name on the server.
|
||||
pub name: String,
|
||||
/// The ids of its share links (the owner's own and the ones made for others).
|
||||
pub links: Vec<String>,
|
||||
/// When it is deleted if nobody opens it (seconds since 1970).
|
||||
pub expires: Option<u64>,
|
||||
}
|
||||
|
||||
/// A file a server keeps.
|
||||
|
|
@ -344,6 +362,9 @@ pub enum AdminReply {
|
|||
Code {
|
||||
code: String,
|
||||
},
|
||||
/// The answer to [`Admin::Owned`], one per pair asked about, in order. `None`: no file of that
|
||||
/// id is managed by that token here (removed, expired, or never there).
|
||||
Owned(Vec<Option<OwnedFile>>),
|
||||
}
|
||||
|
||||
/// A file with what its operator moderates by.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
//! `Admin::Owned` and its answer on the wire: both encodings round-trip, the new variants sit at
|
||||
//! the end of their enums (so every message an older peer reads keeps its bytes), and an older
|
||||
//! peer, which doesn't know them, fails to read them instead of misreading them.
|
||||
|
||||
use crate::message::{Admin, AdminReply, ClientMsg, OwnedFile, ServerMsg, decode, decode_json, encode, encode_json};
|
||||
|
||||
fn asked() -> ClientMsg {
|
||||
ClientMsg::Admin(Admin::Owned { files: vec![("f1".into(), "OwnerToken".into()), (String::new(), String::new())] })
|
||||
}
|
||||
|
||||
fn answered() -> ServerMsg {
|
||||
let file = OwnedFile { name: "Homepage".into(), links: vec!["a".into(), "b".into()], expires: Some(1_900_000_000) };
|
||||
ServerMsg::Admin(Box::new(AdminReply::Owned(vec![Some(file), None])))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_encodings_round_trip() {
|
||||
let (c, s) = (asked(), answered());
|
||||
assert_eq!(decode::<ClientMsg>(&encode(&c).unwrap(), 1 << 20).unwrap(), c);
|
||||
assert_eq!(decode::<ServerMsg>(&encode(&s).unwrap(), 1 << 20).unwrap(), s);
|
||||
let json = encode_json(&c).unwrap();
|
||||
assert!(json.contains(r#""owned":{"files":[["f1","OwnerToken"],["",""]]}"#), "{json}");
|
||||
assert_eq!(decode_json::<ClientMsg>(&json, 1 << 20).unwrap(), c);
|
||||
let json = encode_json(&s).unwrap();
|
||||
assert!(json.contains(r#""owned":[{"name":"Homepage","links":["a","b"],"expires":1900000000},null]"#), "{json}");
|
||||
assert_eq!(decode_json::<ServerMsg>(&json, 1 << 20).unwrap(), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_variants_are_the_last_of_their_enums() {
|
||||
// Postcard writes a variant as its index: the last byte before the payload of an `Admin`
|
||||
// request is the variant's. `Code` was the last one before; `Owned` follows it.
|
||||
let index = |a: Admin| postcard::to_allocvec(&a).unwrap()[0];
|
||||
let code = index(Admin::Code { file: String::new(), token: String::new() });
|
||||
assert_eq!(index(Admin::Owned { files: vec![] }), code + 1);
|
||||
let index = |r: AdminReply| postcard::to_allocvec(&r).unwrap()[0];
|
||||
assert_eq!(index(AdminReply::Owned(vec![])), index(AdminReply::Code { code: String::new() }) + 1);
|
||||
// A peer from before them reads an unknown variant as an error, never as another message:
|
||||
// one index further is as unknown to this version as `Owned` is to an older one.
|
||||
let mut bytes = encode(&asked()).unwrap();
|
||||
let at = bytes.iter().position(|b| *b == code + 1).unwrap();
|
||||
bytes[at] = code + 2;
|
||||
assert!(decode::<ClientMsg>(&bytes, 1 << 20).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_answers_are_refused_by_size_not_trusted() {
|
||||
// A count that promises more than the message holds doesn't allocate or read past the end.
|
||||
let mut bytes = encode(&answered()).unwrap();
|
||||
bytes.truncate(bytes.len() - 3);
|
||||
assert!(decode::<ServerMsg>(&bytes, 1 << 20).is_err());
|
||||
assert!(decode::<ServerMsg>(&encode(&answered()).unwrap(), 4).is_err(), "over the size limit");
|
||||
}
|
||||
|
|
@ -103,6 +103,7 @@ fn registry() -> &'static Registry {
|
|||
list.extend(crate::plugin_directory::specs());
|
||||
list.extend(crate::widgets::specs());
|
||||
list.extend(crate::live::specs());
|
||||
list.extend(crate::online::specs());
|
||||
list.extend(crate::invite_links::specs());
|
||||
let mut index = HashMap::new();
|
||||
for (i, s) in list.iter().enumerate() {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ pub mod library_server;
|
|||
pub mod library_swap;
|
||||
pub mod live;
|
||||
pub mod measure;
|
||||
pub mod online;
|
||||
pub mod params;
|
||||
pub mod plugin_directory;
|
||||
pub mod plugins;
|
||||
|
|
@ -580,6 +581,10 @@ pub struct Prefs {
|
|||
/// Recently opened or saved files, newest first (File ▸ Open recent; at most
|
||||
/// [`cmd::file::MAX_RECENT`]).
|
||||
pub recent_files: Vec<String>,
|
||||
/// The files you put on a server without its key, with their owner tokens (passwords: see
|
||||
/// [`online`]), most recently opened first, at most [`online::MAX_ONLINE_FILES`].
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", deserialize_with = "online::read")]
|
||||
pub online_files: Vec<online::OnlineFile>,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
|
|
@ -603,6 +608,7 @@ impl Default for Prefs {
|
|||
plugin_directory: None,
|
||||
publish_server: live::DEFAULT_SERVER.into(),
|
||||
recent_files: Vec::new(),
|
||||
online_files: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1425,6 +1431,8 @@ mod menu_gaps_tests;
|
|||
#[cfg(test)]
|
||||
mod nested_override_tests;
|
||||
#[cfg(test)]
|
||||
mod online_tests;
|
||||
#[cfg(test)]
|
||||
mod outline_options_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_reorder_tests;
|
||||
|
|
|
|||
|
|
@ -196,7 +196,8 @@ pub enum Via {
|
|||
}
|
||||
|
||||
/// One way into a shared file: an invite link, and the join code that stands for it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SharedLink {
|
||||
/// The link's id on its server (what revokes it; empty for a session this app hosts).
|
||||
pub id: String,
|
||||
|
|
@ -208,7 +209,8 @@ pub struct SharedLink {
|
|||
}
|
||||
|
||||
/// What others join a shared file with: to edit it and, on a server, to only view it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Shared {
|
||||
pub edit: SharedLink,
|
||||
pub view: Option<SharedLink>,
|
||||
|
|
@ -554,7 +556,11 @@ pub struct Live {
|
|||
deferred: Vec<ServerMsg>,
|
||||
/// Nesting of [`Session::grouped`] on the session document: its edits sync once, at the end.
|
||||
hold: u32,
|
||||
/// The file you published to a server in this session, with its links.
|
||||
/// The address of the server this session's file was published to, or is yours on (empty
|
||||
/// otherwise; it may carry the server's key: never shown).
|
||||
pub server: String,
|
||||
/// The file you published to a server in this session, or one of yours you opened again
|
||||
/// (`collab.openFile`: without its owner token, which stays in the record), with its links.
|
||||
pub published: Option<Published>,
|
||||
/// The session was started by sharing with a join code (`collab.share`): how, and its codes.
|
||||
pub share: Option<Share>,
|
||||
|
|
@ -599,6 +605,19 @@ impl Live {
|
|||
pub fn syncing(&self) -> bool {
|
||||
self.client.is_some()
|
||||
}
|
||||
|
||||
/// What the file's tab says about its session: `Some(true)` others get in (a session this app
|
||||
/// hosts, one you joined, a file of yours shared with a code or links), `Some(false)` a file of
|
||||
/// yours on a server that only you get into now, `None` no session (or one still on its way).
|
||||
pub fn shared(&self) -> Option<bool> {
|
||||
let there = matches!(self.status, Status::Live | Status::Reconnecting { .. });
|
||||
there.then(|| self.share.as_ref().is_none_or(|s| s.links.is_some()))
|
||||
}
|
||||
|
||||
/// The name of the session's file, once known.
|
||||
fn file_name(&self) -> Option<&str> {
|
||||
self.info.as_ref().map(|i| i.file_name.as_str()).or(self.published.as_ref().map(|p| p.name.as_str())).filter(|n| !n.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's live sessions, one per document at most, and what they share: the networking and
|
||||
|
|
@ -614,6 +633,11 @@ pub struct Lives {
|
|||
/// Registers the app as the opener of `figcraft://` invite links (the desktop app on Windows
|
||||
/// installs one; see [`crate::invite_links`]).
|
||||
pub links: Option<Box<dyn crate::invite_links::LinkRegistrar>>,
|
||||
/// The recorded online files changed since the app last saved the preferences (it saves them
|
||||
/// at once: a crash must not lose an owner token).
|
||||
pub unsaved: bool,
|
||||
/// Servers that couldn't say what became of your files at the last `collab.files {refresh}`.
|
||||
pub servers: crate::online::Problems,
|
||||
}
|
||||
|
||||
impl Lives {
|
||||
|
|
@ -647,6 +671,10 @@ impl Lives {
|
|||
self.sessions.values()
|
||||
}
|
||||
|
||||
pub(crate) fn sessions_mut(&mut self) -> impl Iterator<Item = &mut Live> {
|
||||
self.sessions.values_mut()
|
||||
}
|
||||
|
||||
/// The session the tab of document `tab` shows (`None`: no document is open): the one that
|
||||
/// syncs it, else the latest join started from it.
|
||||
fn shown(&self, tab: Option<u64>) -> Option<SessionId> {
|
||||
|
|
@ -691,7 +719,7 @@ impl Lives {
|
|||
}
|
||||
|
||||
/// End session `id` (stop hosting or leave) and forget it.
|
||||
fn end(&mut self, id: SessionId) -> Option<Live> {
|
||||
pub(crate) fn end(&mut self, id: SessionId) -> Option<Live> {
|
||||
self.leave(id);
|
||||
self.sessions.remove(&id)
|
||||
}
|
||||
|
|
@ -898,7 +926,11 @@ impl Session {
|
|||
e => e,
|
||||
};
|
||||
// A session left meanwhile: what was still on its way is dropped.
|
||||
let front = self.tab();
|
||||
let Some(live) = self.live.sessions.get_mut(&id) else { return };
|
||||
// What happens in a file that isn't in front says which file (by its tab's name).
|
||||
let open = self.docs.iter().find(|d| Some(d.uid) == live.doc_uid).map(|d| d.doc.name.as_str());
|
||||
let behind = open.or(live.file_name()).filter(|_| live.tab != front).map(|n| format!(" {n}")).unwrap_or_default();
|
||||
match e {
|
||||
Event::Status(Status::Failed { reason }) => self.live_fail(id, reason),
|
||||
Event::Status(Status::Off) => {
|
||||
|
|
@ -939,7 +971,7 @@ impl Session {
|
|||
live.sent = None;
|
||||
}
|
||||
Event::PeerJoined(p) => {
|
||||
self.message = Some(format!("{} joined", p.name));
|
||||
self.message = Some(format!("{} joined{behind}", p.name));
|
||||
live.peers.insert(p.client_id, p);
|
||||
}
|
||||
Event::PeerLeft(peer) => {
|
||||
|
|
@ -949,7 +981,7 @@ impl Session {
|
|||
live.spotlight = None;
|
||||
}
|
||||
if let Some(p) = live.peers.remove(&peer) {
|
||||
self.message = Some(format!("{} left", p.name));
|
||||
self.message = Some(format!("{} left{behind}", p.name));
|
||||
}
|
||||
if live.follow == Some(peer) {
|
||||
live.unfollow();
|
||||
|
|
@ -966,7 +998,7 @@ impl Session {
|
|||
// order.
|
||||
Event::Sync(msg) => live.deferred.push(msg),
|
||||
Event::Ended { reason } => {
|
||||
self.message = Some(reason);
|
||||
self.message = Some(if behind.is_empty() { reason } else { format!("{}: {reason}", behind.trim_start()) });
|
||||
self.live.end(id);
|
||||
}
|
||||
Event::Published(p) => {
|
||||
|
|
@ -974,11 +1006,19 @@ impl Session {
|
|||
if live.share.is_none() {
|
||||
self.message = Some(format!("Published {} to the server", p.name));
|
||||
}
|
||||
live.published = Some(p);
|
||||
let server = live.server.clone();
|
||||
live.published = Some(p.clone());
|
||||
self.online_published(&server, &p);
|
||||
}
|
||||
Event::Shared(Ok(links)) => {
|
||||
if let Some(share) = live.share.as_mut() {
|
||||
share.links = Some(clean_shared(links));
|
||||
let links = clean_shared(links);
|
||||
match live.server_file() {
|
||||
Some((server, file)) => self.online_shared(&server, &file, Some(links)),
|
||||
None => {
|
||||
if let Some(share) = live.share.as_mut() {
|
||||
share.links = Some(links);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Shared(Err(why)) => self.message = Some(format!("The file is on the server, but couldn't be shared: {why}")),
|
||||
|
|
@ -1569,7 +1609,7 @@ fn start_publish(s: &mut Session, cmd: &str, server: &str, p: &Value, share: boo
|
|||
let opts = PublishOptions { server: server.trim().to_string(), key, name: name.unwrap_or_else(|| doc.name.clone()), me, doc, share };
|
||||
let (id, ()) = s.live.start(cmd, |l, id| l.publish(id, opts).map_err(|e| bad(cmd, e)))?;
|
||||
// This tab becomes the server file's synced document once it is there.
|
||||
s.live.open(Live { id, status: Status::Connecting, doc_uid: Some(uid), tab: Some(uid), ..Live::default() });
|
||||
s.live.open(Live { id, status: Status::Connecting, doc_uid: Some(uid), tab: Some(uid), server: server.trim().to_string(), ..Live::default() });
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
|
|
@ -1600,11 +1640,9 @@ fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if access == Some(Access::View) && shared == Via::Local {
|
||||
return Err(bad(CMD, LOCAL_EDITS));
|
||||
}
|
||||
if let Some(req) = share_request(s.live(), Vec::new(), true).filter(|_| s.live().share.as_ref().is_some_and(|sh| sh.links.is_none())) {
|
||||
if let Some(req) = share_request(s, Vec::new(), true).filter(|_| s.live().share.as_ref().is_some_and(|sh| sh.links.is_none())) {
|
||||
let links = share_answer(s, req)?.ok_or_else(|| EngineError::Other("the server made no links".into()))?;
|
||||
if let Some(sh) = s.live_mut().and_then(|l| l.share.as_mut()) {
|
||||
sh.links = Some(clean_shared(links));
|
||||
}
|
||||
shared_now(s, Some(clean_shared(links)));
|
||||
}
|
||||
if let (Some(a), Some(sh)) = (access, s.live_mut().and_then(|l| l.share.as_mut())) {
|
||||
sh.access = a;
|
||||
|
|
@ -1636,27 +1674,36 @@ fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(s.live_summary())
|
||||
}
|
||||
|
||||
/// What others join the open file's server file with changed: in its session and its record.
|
||||
fn shared_now(s: &mut Session, links: Option<Shared>) {
|
||||
if let Some((server, file)) = s.live().server_file() {
|
||||
s.online_shared(&server, &file, links);
|
||||
}
|
||||
}
|
||||
|
||||
/// Links and codes as a server gave them, with only codes that are codes (nine symbols).
|
||||
fn clean_shared(mut links: Shared) -> Shared {
|
||||
pub(crate) fn clean_shared(mut links: Shared) -> Shared {
|
||||
let clean = |l: &mut SharedLink| l.code = l.code.take().and_then(|c| code::normalize(&c).ok()).filter(|c| c.len() == code::ONLINE_LEN);
|
||||
clean(&mut links.edit);
|
||||
links.view.as_mut().map(clean);
|
||||
links
|
||||
}
|
||||
|
||||
/// The request that changes who else gets into the server file `live` is its owner's session of
|
||||
/// (`None`: it isn't one, or the file isn't there yet).
|
||||
fn share_request(live: &Live, revoke: Vec<String>, create: bool) -> Option<NetRequest> {
|
||||
/// The request that changes who else gets into the server file the open file's session is its
|
||||
/// owner's session of (`None`: it isn't one, or the file isn't there yet).
|
||||
fn share_request(s: &Session, revoke: Vec<String>, create: bool) -> Option<NetRequest> {
|
||||
let live = s.live();
|
||||
let (share, published) = (live.share.as_ref().filter(|s| s.via == Via::Online)?, live.published.as_ref()?);
|
||||
if live.status != Status::Live {
|
||||
return None;
|
||||
}
|
||||
let owner = published.owner.clone().unwrap_or_default();
|
||||
// The token this session got when it published the file, else the recorded one.
|
||||
let owner = published.owner.clone().or_else(|| s.online_owner(&share.server, &published.file)).unwrap_or_default();
|
||||
Some(NetRequest::Share { server: share.server.clone(), file: published.file.clone(), owner, revoke, create })
|
||||
}
|
||||
|
||||
/// Send `req` ([`NetRequest::Share`]): the new links, if it made any.
|
||||
fn share_answer(s: &mut Session, req: NetRequest) -> Result<Option<Shared>> {
|
||||
pub(crate) fn share_answer(s: &mut Session, req: NetRequest) -> Result<Option<Shared>> {
|
||||
match s.net(req)?.0 {
|
||||
NetReply::Share(Ok(links)) => Ok(links),
|
||||
NetReply::Share(Err(why)) => Err(EngineError::Other(why)),
|
||||
|
|
@ -1685,10 +1732,13 @@ fn server_info(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
fn manage_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.manageFile";
|
||||
let (server, file, owner) = (req_string(CMD, p, "server")?, req_string(CMD, p, "file")?, req_string(CMD, p, "owner")?);
|
||||
let (file, owner) = (file.trim().to_string(), owner.trim().to_string());
|
||||
let file = req_string(CMD, p, "file")?.trim().to_string();
|
||||
let (server, owner) = crate::online::manager(s, CMD, p, &file)?;
|
||||
let action = req_string(CMD, p, "action")?;
|
||||
let mut renamed = None;
|
||||
let req = match action.trim() {
|
||||
"share" => return crate::online::set_shared(s, CMD, &server, &file, &owner, true),
|
||||
"unshare" => return crate::online::set_shared(s, CMD, &server, &file, &owner, false),
|
||||
"links" => Admin::Links { file: file.clone() },
|
||||
"createLink" => {
|
||||
let access = match string(p, "access").as_deref().map(str::trim) {
|
||||
|
|
@ -1699,11 +1749,24 @@ fn manage_file(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Admin::CreateLink { file: file.clone(), access, label: string(p, "label").unwrap_or_default() }
|
||||
}
|
||||
"revoke" => Admin::Revoke { file: file.clone(), link: req_string(CMD, p, "link")?.trim().to_string() },
|
||||
"rename" => Admin::Rename { file: file.clone(), name: req_string(CMD, p, "name")? },
|
||||
"rename" => {
|
||||
let name = req_string(CMD, p, "name")?;
|
||||
renamed = Some(name.clone());
|
||||
Admin::Rename { file: file.clone(), name }
|
||||
}
|
||||
"remove" => Admin::Remove { file: file.clone() },
|
||||
_ => return Err(bad(CMD, "`action` must be links, createLink, revoke, rename or remove")),
|
||||
_ => return Err(bad(CMD, "`action` must be links, createLink, revoke, share, unshare, rename or remove")),
|
||||
};
|
||||
match hosting_request(s, CMD, server.trim(), &file, &owner, Some(req))?.1 {
|
||||
let changes = matches!(req, Admin::Rename { .. } | Admin::Remove { .. });
|
||||
let reply = hosting_request(s, CMD, &server, &file, &owner, Some(req))?.1;
|
||||
// The record follows what the server did.
|
||||
if changes && reply == Some(AdminReply::Done) {
|
||||
crate::online::managed(s, &server, &file, renamed.as_deref());
|
||||
if renamed.is_none() {
|
||||
crate::online::removed(s, &server, &file);
|
||||
}
|
||||
}
|
||||
match reply {
|
||||
Some(AdminReply::Links(links)) => Ok(json!({"links": links.iter().map(|l| json!({
|
||||
"id": l.id, "access": l.access.id(), "label": l.label, "created": l.created
|
||||
})).collect::<Vec<_>>()})),
|
||||
|
|
@ -1742,13 +1805,21 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
None => req_string(CMD, p, "link").map_err(|_| bad(CMD, "give the join `code` you were told, or the invite `link`"))?,
|
||||
};
|
||||
let link = join_target(&s.prefs.publish_server, &typed).map_err(|e| bad(CMD, e))?;
|
||||
let (name, tab) = (display_name(&s.prefs), s.tab());
|
||||
let (id, ()) = s.live.start(CMD, |l, id| l.join(id, &link, &name).map_err(|e| bad(CMD, e)))?;
|
||||
// The session's file opens in a tab of its own; until then the join shows on this one.
|
||||
s.live.open(Live { id, status: Status::Connecting, tab, ..Live::default() });
|
||||
let id = start_join(s, CMD, &link, None, None)?;
|
||||
Ok(s.live.get(id).map_or(Value::Null, |l| s.summary(l)))
|
||||
}
|
||||
|
||||
/// Start joining the session behind `link` for command `cmd`: the new session. `published` and
|
||||
/// `share`: the file is yours on a server (`collab.openFile`), with what others join it with.
|
||||
pub(crate) fn start_join(s: &mut Session, cmd: &str, link: &str, published: Option<Published>, share: Option<Share>) -> Result<SessionId> {
|
||||
let (name, tab) = (display_name(&s.prefs), s.tab());
|
||||
let (id, ()) = s.live.start(cmd, |l, id| l.join(id, link, &name).map_err(|e| bad(cmd, e)))?;
|
||||
let server = share.as_ref().map(|s| s.server.clone()).unwrap_or_default();
|
||||
// The session's file opens in a tab of its own; until then the join shows on this one.
|
||||
s.live.open(Live { id, status: Status::Connecting, tab, server, published, share, ..Live::default() });
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn leave(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ended = s.live.shown(s.tab()).and_then(|id| s.live.end(id));
|
||||
if ended.as_ref().is_some_and(|l| l.syncing() && l.role() == Some(Role::Guest)) {
|
||||
|
|
@ -1763,11 +1834,9 @@ fn stop(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
// file. Anything else (a session this app hosts, one it joined, a file not shared now) ends.
|
||||
let shared = s.live().share.as_ref().and_then(|sh| sh.links.as_ref());
|
||||
let ids: Vec<String> = shared.into_iter().flat_map(|l| [Some(&l.edit), l.view.as_ref()]).flatten().map(|l| l.id.clone()).collect();
|
||||
let Some(req) = share_request(s.live(), ids, false).filter(|_| shared.is_some()) else { return leave(s, p) };
|
||||
let Some(req) = share_request(s, ids, false).filter(|_| shared.is_some()) else { return leave(s, p) };
|
||||
share_answer(s, req).map_err(|e| EngineError::Other(format!("couldn't stop sharing (the others can still get in): {e}")))?;
|
||||
if let Some(sh) = s.live_mut().and_then(|l| l.share.as_mut()) {
|
||||
sh.links = None;
|
||||
}
|
||||
shared_now(s, None);
|
||||
s.message = Some("Sharing stopped: the file stays on the server, open only to you".into());
|
||||
Ok(json!({"ended": false, "shared": false}))
|
||||
}
|
||||
|
|
@ -1874,7 +1943,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"collab.manageFile",
|
||||
"Manage published file",
|
||||
"{server, file (its id), owner (the owner token you got when you published it without the key), action: links | createLink {access?: view (default) | edit, label?} | revoke {link (a link's id)} | rename {name} | remove} → {links: [{id, access, label, created}]} | {id, access, link (a new share link, shown once)} | {done}",
|
||||
"{file (its id), server?, owner? (the server's address and the owner token you got when you published the file without the key; each defaults to what is recorded for one of your online files, see `collab.files`), action: links | createLink {access?: view (default) | edit, label?} | revoke {link (a link's id)} | share (others get new links and join codes) | unshare (the links and codes others got are revoked; the file stays yours on the server) | rename {name} | remove (the file is deleted from the server and leaves your online files)} → {links: [{id, access, label, created}]} | {id, access, link (a new share link, shown once)} | {shared, code, viewCode, editLink, viewLink} | {done}",
|
||||
false,
|
||||
manage_file
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,543 @@
|
|||
//! Your online files (milestone M9): the files you put on a server stay yours after their tab
|
||||
//! closes or the app quits.
|
||||
//!
|
||||
//! Publishing or sharing a file online without the server's key makes you its owner: the server
|
||||
//! hands out an owner token once. The app records each such file with the preferences
|
||||
//! ([`Prefs::online_files`](crate::Prefs)), never in a document: where it is, its name, the owner
|
||||
//! token, your own links to it and what others join with while it is shared. The token is a
|
||||
//! password: it is stored on this computer only, isn't logged, and no command result or summary
|
||||
//! carries it (they say `hasOwnerToken: true`); `Debug` leaves the secrets out.
|
||||
//!
|
||||
//! `collab.files` lists them (asking each server once what became of them, when told to),
|
||||
//! `collab.openFile` joins one again as its owner, `collab.forgetFile` drops one from the list
|
||||
//! (the file stays on its server) and `collab.manageFile` shares, unshares, renames or removes
|
||||
//! one with the recorded token.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use figcraft_collab::code;
|
||||
use figcraft_collab::message::{Admin, AdminReply, OwnedFile};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::live::{Access, Live, NetReply, NetRequest, Published, Share, Shared, SharedLink, Status, Via, clean_shared};
|
||||
use crate::params::{req_string, string};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
/// Most files remembered: recording another drops the one opened longest ago.
|
||||
pub const MAX_ONLINE_FILES: usize = 200;
|
||||
|
||||
/// What a file's server said about it when it was last asked.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FileState {
|
||||
/// Not asked yet in this run, or the server couldn't say.
|
||||
#[default]
|
||||
Unknown,
|
||||
/// On the server.
|
||||
Online,
|
||||
/// The server no longer has it for this owner (removed or expired).
|
||||
Gone,
|
||||
}
|
||||
|
||||
/// A file of yours on a server (see the module docs). Most recently opened first in the list.
|
||||
#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct OnlineFile {
|
||||
/// The server's address (`wss://host/`), never with a key.
|
||||
pub server: String,
|
||||
/// The file's id and name there.
|
||||
pub file: String,
|
||||
pub name: String,
|
||||
/// The owner token: a password. Never logged or put in a command's result.
|
||||
pub owner: String,
|
||||
/// Your own links to the file (they carry their secrets).
|
||||
pub edit: String,
|
||||
pub view: String,
|
||||
/// When it was put on the server and when it was last opened here (seconds since 1970).
|
||||
pub created: u64,
|
||||
pub opened: u64,
|
||||
/// What others join with while it is shared (`None`: open only to you).
|
||||
pub shared: Option<Shared>,
|
||||
/// What the server said last (this run only).
|
||||
#[serde(skip)]
|
||||
pub state: FileState,
|
||||
/// When the server deletes it unless someone opens it (this run only).
|
||||
#[serde(skip)]
|
||||
pub expires: Option<u64>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OnlineFile {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("OnlineFile")
|
||||
.field("server", &self.server)
|
||||
.field("file", &self.file)
|
||||
.field("name", &self.name)
|
||||
.field("shared", &self.shared.is_some())
|
||||
.field("state", &self.state)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a server's files couldn't be asked about at the last refresh.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ServerProblem {
|
||||
/// It couldn't be reached, or refused (the reason).
|
||||
Offline(String),
|
||||
/// It is older than this request: what this computer remembers is shown as it is.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// Servers whose files couldn't be asked about at the last refresh, by address.
|
||||
pub type Problems = BTreeMap<String, ServerProblem>;
|
||||
|
||||
/// A name as shown: no control characters, at most 256 characters.
|
||||
fn clean_name(s: &str) -> String {
|
||||
s.trim().chars().filter(|c| !c.is_control()).take(256).collect()
|
||||
}
|
||||
|
||||
/// A server address without its query, as recorded.
|
||||
pub(crate) fn plain_server(server: &str) -> String {
|
||||
server.split('?').next().unwrap_or_default().trim().to_string()
|
||||
}
|
||||
|
||||
/// Two addresses name the same server (case, a closing slash and the scheme's own port aside).
|
||||
pub fn same_server(a: &str, b: &str) -> bool {
|
||||
let norm = |s: &str| {
|
||||
let s = plain_server(s).to_ascii_lowercase();
|
||||
let s = s.trim_end_matches('/');
|
||||
let port = if s.starts_with("wss://") { ":443" } else { ":80" };
|
||||
s.strip_suffix(port).unwrap_or(s).to_string()
|
||||
};
|
||||
norm(a) == norm(b)
|
||||
}
|
||||
|
||||
/// The host a server address names (`wss://share.example/` is `share.example`).
|
||||
pub fn server_host(server: &str) -> &str {
|
||||
let plain = server.split('?').next().unwrap_or_default();
|
||||
plain.split_once("://").map_or(plain, |(_, rest)| rest).trim_end_matches('/')
|
||||
}
|
||||
|
||||
fn secret(s: &str, max: usize) -> bool {
|
||||
!s.is_empty() && s.len() <= max && !s.contains(|c: char| c.is_control() || c.is_whitespace())
|
||||
}
|
||||
|
||||
fn link(s: &str) -> bool {
|
||||
secret(s, 2048) && s.contains("://")
|
||||
}
|
||||
|
||||
impl OnlineFile {
|
||||
/// The record made safe to use, or `None` when it can't be one (anything may be in a
|
||||
/// preferences file).
|
||||
fn clean(mut self) -> Option<OnlineFile> {
|
||||
self.server = plain_server(&self.server);
|
||||
let server = (self.server.starts_with("ws://") || self.server.starts_with("wss://")) && secret(&self.server, 512);
|
||||
let id = (1..=64).contains(&self.file.len()) && self.file.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
|
||||
if !(server && id && secret(&self.owner, 512) && link(&self.edit)) {
|
||||
return None;
|
||||
}
|
||||
if !link(&self.view) {
|
||||
self.view.clear();
|
||||
}
|
||||
self.name = clean_name(&self.name);
|
||||
let usable = |l: &SharedLink| link(&l.link) && l.id.len() <= 64 && !l.id.contains(char::is_control);
|
||||
self.shared = self.shared.take().filter(|s| usable(&s.edit)).map(|mut s| {
|
||||
s.view = s.view.take().filter(usable);
|
||||
clean_shared(s)
|
||||
});
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn is(&self, server: &str, file: &str) -> bool {
|
||||
self.file == file && same_server(&self.server, server)
|
||||
}
|
||||
}
|
||||
|
||||
/// The usable records of `list`, one per file, at most [`MAX_ONLINE_FILES`] (the first ones: the
|
||||
/// list is most recently opened first).
|
||||
fn clean(list: impl IntoIterator<Item = OnlineFile>) -> Vec<OnlineFile> {
|
||||
let mut out: Vec<OnlineFile> = Vec::new();
|
||||
for f in list.into_iter().filter_map(OnlineFile::clean) {
|
||||
if out.len() < MAX_ONLINE_FILES && !out.iter().any(|o| o.is(&f.server, &f.file)) {
|
||||
out.push(f);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Reads the list from the preferences: whatever isn't a usable record is skipped, so junk there
|
||||
/// never costs the other preferences.
|
||||
pub(crate) fn read<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Vec<OnlineFile>, D::Error> {
|
||||
let v = Value::deserialize(d).unwrap_or(Value::Null);
|
||||
let entries = v.as_array().map(Vec::as_slice).unwrap_or_default();
|
||||
Ok(clean(entries.iter().take(4 * MAX_ONLINE_FILES).filter_map(|e| serde_json::from_value(e.clone()).ok())))
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| d.as_secs())
|
||||
}
|
||||
|
||||
impl Live {
|
||||
/// The server file this session is in as its owner or publisher: `(server, file id)`.
|
||||
pub(crate) fn server_file(&self) -> Option<(String, String)> {
|
||||
let p = self.published.as_ref()?;
|
||||
let server = self.share.as_ref().filter(|s| s.via == Via::Online).map_or(self.server.as_str(), |s| s.server.as_str());
|
||||
Some((server.to_string(), p.file.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Where the recorded file `file` on `server` is in the list.
|
||||
fn online_index(&self, server: &str, file: &str) -> Option<usize> {
|
||||
self.prefs.online_files.iter().position(|f| f.is(server, file))
|
||||
}
|
||||
|
||||
/// The recorded file command `cmd` names with `file` (and `server`, when several servers have
|
||||
/// a file of that id).
|
||||
fn online_named(&self, cmd: &str, file: &str, server: Option<&str>) -> Result<usize> {
|
||||
let mut hits = self.prefs.online_files.iter().enumerate().filter(|(_, f)| f.file == file && server.is_none_or(|s| same_server(&f.server, s)));
|
||||
match (hits.next(), hits.next()) {
|
||||
(Some((i, _)), None) => Ok(i),
|
||||
(Some(_), Some(_)) => Err(bad(cmd, "files on several servers have that id: say which with `server`")),
|
||||
_ => Err(bad(cmd, "no online file with that id is recorded on this computer (`collab.files` lists them)")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The owner token recorded for `file` on `server`.
|
||||
pub(crate) fn online_owner(&self, server: &str, file: &str) -> Option<String> {
|
||||
self.online_index(server, file).and_then(|i| self.prefs.online_files.get(i)).map(|f| f.owner.clone())
|
||||
}
|
||||
|
||||
/// The list changed: the app saves the preferences at once (a crash must not lose a token).
|
||||
fn online_changed(&mut self) {
|
||||
self.prefs.online_files.truncate(MAX_ONLINE_FILES);
|
||||
self.live.unsaved = true;
|
||||
}
|
||||
|
||||
/// A file reached `server` (`collab.publish`, `collab.share`): remember it when it came with
|
||||
/// an owner token (one published with the server's key is the operator's to manage).
|
||||
pub(crate) fn online_published(&mut self, server: &str, p: &Published) {
|
||||
let Some(owner) = p.owner.clone() else { return };
|
||||
let t = now();
|
||||
let record = OnlineFile {
|
||||
server: plain_server(server),
|
||||
file: p.file.clone(),
|
||||
name: p.name.clone(),
|
||||
owner,
|
||||
edit: p.edit.clone(),
|
||||
view: p.view.clone(),
|
||||
created: t,
|
||||
opened: t,
|
||||
state: FileState::Online,
|
||||
expires: p.expires,
|
||||
..OnlineFile::default()
|
||||
};
|
||||
let Some(record) = record.clean() else { return };
|
||||
self.prefs.online_files.retain(|f| !f.is(&record.server, &record.file));
|
||||
self.prefs.online_files.insert(0, record);
|
||||
self.online_changed();
|
||||
}
|
||||
|
||||
/// What others join `file` on `server` with changed (`None`: nobody else gets in now): in the
|
||||
/// record, and in the sessions open on the file.
|
||||
pub(crate) fn online_shared(&mut self, server: &str, file: &str, links: Option<Shared>) {
|
||||
for l in self.live.sessions_mut().filter(|l| l.server_file().is_some_and(|(s, f)| f == file && same_server(&s, server))) {
|
||||
if let Some(sh) = l.share.as_mut() {
|
||||
sh.links.clone_from(&links);
|
||||
}
|
||||
}
|
||||
if let Some(f) = self.online_index(server, file).and_then(|i| self.prefs.online_files.get_mut(i)).filter(|f| f.shared != links) {
|
||||
f.shared = links;
|
||||
self.online_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// The recorded file document `uid` is open on as its owner, in step with the server:
|
||||
/// `(server, file id)` (what reopening it after a crash takes).
|
||||
pub fn online_of(&self, uid: u64) -> Option<(String, String)> {
|
||||
let live = self.live_of(uid);
|
||||
let (server, file) = live.server_file().filter(|_| live.status == Status::Live && live.doc_uid == Some(uid) && live.syncing())?;
|
||||
self.online_index(&server, &file).map(|_| (plain_server(&server), file))
|
||||
}
|
||||
|
||||
/// The session open on recorded file `f`, if one is.
|
||||
fn online_session(&self, f: &OnlineFile) -> Option<&Live> {
|
||||
self.live.iter().find(|l| l.status.active() && l.server_file().is_some_and(|(s, id)| f.is(&s, &id)))
|
||||
}
|
||||
|
||||
/// Ask every server once what became of the files recorded on it.
|
||||
fn online_refresh(&mut self) -> Result<()> {
|
||||
let mut servers: Vec<String> = Vec::new();
|
||||
for f in &self.prefs.online_files {
|
||||
if !servers.iter().any(|s| same_server(s, &f.server)) {
|
||||
servers.push(f.server.clone());
|
||||
}
|
||||
}
|
||||
for server in servers {
|
||||
let asked: Vec<(String, String)> =
|
||||
self.prefs.online_files.iter().filter(|f| same_server(&f.server, &server)).map(|f| (f.file.clone(), f.owner.clone())).collect();
|
||||
let req =
|
||||
NetRequest::Hosting { server: server.clone(), file: String::new(), owner: String::new(), req: Some(Admin::Owned { files: asked }) };
|
||||
// What was asked when the answer is an earlier one (a command run in the background).
|
||||
let (reply, earlier) = self.net(req.clone())?;
|
||||
let asked = match earlier.unwrap_or(req) {
|
||||
NetRequest::Hosting { req: Some(Admin::Owned { files }), .. } => files,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let problem = match reply {
|
||||
NetReply::Hosting(Ok((_, Some(AdminReply::Owned(answers))))) if answers.len() == asked.len() => {
|
||||
for ((file, _), answer) in asked.iter().zip(answers) {
|
||||
self.online_answer(&server, file, answer);
|
||||
}
|
||||
None
|
||||
}
|
||||
// A server from before this request can't read it.
|
||||
NetReply::Hosting(Err(why)) if why.contains("couldn't be read") => Some(ServerProblem::Unsupported),
|
||||
NetReply::Hosting(Err(why)) | NetReply::Hosting(Ok((_, Some(AdminReply::Failed { reason: why })))) => {
|
||||
Some(ServerProblem::Offline(why))
|
||||
}
|
||||
_ => Some(ServerProblem::Offline("the server answered something else".into())),
|
||||
};
|
||||
self.live.servers.retain(|s, _| !same_server(s, &server));
|
||||
if let Some(p) = problem {
|
||||
self.live.servers.insert(server, p);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What `server` said about recorded file `file`: there (with its name, links and expiry), or
|
||||
/// gone.
|
||||
fn online_answer(&mut self, server: &str, file: &str, answer: Option<OwnedFile>) {
|
||||
let Some(i) = self.online_index(server, file) else { return };
|
||||
let Some(f) = self.prefs.online_files.get_mut(i) else { return };
|
||||
let before = (f.name.clone(), f.shared.is_some());
|
||||
// Links revoked from elsewhere no longer let anyone in.
|
||||
let live = |f: &OnlineFile, o: &OwnedFile| {
|
||||
f.shared.as_ref().is_some_and(|s| o.links.contains(&s.edit.id) && s.view.as_ref().is_none_or(|v| o.links.contains(&v.id)))
|
||||
};
|
||||
let shared = match answer {
|
||||
Some(o) => {
|
||||
f.state = FileState::Online;
|
||||
f.expires = o.expires;
|
||||
let name = clean_name(&o.name);
|
||||
if !name.is_empty() {
|
||||
f.name = name;
|
||||
}
|
||||
live(f, &o)
|
||||
}
|
||||
None => {
|
||||
f.state = FileState::Gone;
|
||||
f.expires = None;
|
||||
false
|
||||
}
|
||||
};
|
||||
if before.1 && !shared {
|
||||
self.online_shared(server, file, None);
|
||||
} else if self.prefs.online_files.get(i).is_some_and(|f| f.name != before.0) {
|
||||
self.online_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// The list as `collab.files` gives it: no token, no link of yours.
|
||||
fn online_json(&self) -> Value {
|
||||
let files: Vec<Value> = self
|
||||
.prefs
|
||||
.online_files
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let session = self.online_session(f);
|
||||
let code = |l: Option<&SharedLink>| l.and_then(|l| l.code.as_deref()).map(code::grouped);
|
||||
json!({
|
||||
"server": f.server,
|
||||
"host": server_host(&f.server),
|
||||
"file": f.file,
|
||||
"name": f.name,
|
||||
"created": f.created,
|
||||
"opened": f.opened,
|
||||
"shared": f.shared.is_some(),
|
||||
"code": code(f.shared.as_ref().map(|s| &s.edit)),
|
||||
"viewCode": code(f.shared.as_ref().and_then(|s| s.view.as_ref())),
|
||||
"state": f.state,
|
||||
"expires": f.expires,
|
||||
"session": session.map(|l| l.id),
|
||||
"document": session.and_then(|l| l.doc_uid).and_then(|u| self.docs.iter().position(|d| d.uid == u)),
|
||||
"hasOwnerToken": true,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let servers: Vec<Value> = self
|
||||
.live
|
||||
.servers
|
||||
.iter()
|
||||
.map(|(server, p)| match p {
|
||||
ServerProblem::Offline(why) => json!({"server": server, "host": server_host(server), "state": "offline", "reason": why}),
|
||||
ServerProblem::Unsupported => json!({"server": server, "host": server_host(server), "state": "unsupported", "reason": Value::Null}),
|
||||
})
|
||||
.collect();
|
||||
json!({"files": files, "problems": servers, "max": MAX_ONLINE_FILES})
|
||||
}
|
||||
}
|
||||
|
||||
fn files(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match p.get("refresh") {
|
||||
None => {}
|
||||
Some(v) if v.as_bool() == Some(false) => {}
|
||||
Some(v) if v.as_bool() == Some(true) => s.online_refresh()?,
|
||||
Some(_) => return Err(bad("collab.files", "`refresh` must be true or false")),
|
||||
}
|
||||
Ok(s.online_json())
|
||||
}
|
||||
|
||||
/// The recorded file `cmd`'s `file` (and `server`) name.
|
||||
fn named(s: &Session, cmd: &str, p: &Value) -> Result<usize> {
|
||||
let file = req_string(cmd, p, "file")?;
|
||||
s.online_named(cmd, file.trim(), string(p, "server").as_deref().map(str::trim))
|
||||
}
|
||||
|
||||
fn open_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.openFile";
|
||||
let i = named(s, CMD, p)?;
|
||||
let Some(f) = s.prefs.online_files.get(i).cloned() else { return Err(bad(CMD, "no such file")) };
|
||||
if f.state == FileState::Gone {
|
||||
return Err(bad(CMD, "this file is no longer on its server (it was removed or expired): `collab.forgetFile` clears it from the list"));
|
||||
}
|
||||
let brief = |session: u64, status: &str, document: Option<usize>, open: bool| json!({"session": session, "status": status, "document": document, "alreadyOpen": open, "file": f.file, "server": f.server, "name": f.name});
|
||||
// Open already: its tab comes to the front.
|
||||
if let Some((id, status, uid)) = s.online_session(&f).map(|l| (l.id, l.status.id(), l.doc_uid)) {
|
||||
let document = uid.and_then(|u| s.docs.iter().position(|d| d.uid == u));
|
||||
if let Some(d) = document {
|
||||
s.active = d;
|
||||
}
|
||||
return Ok(brief(id, status, document, true));
|
||||
}
|
||||
// Yours on the server: its sharing is this session's to start and stop. The token stays in
|
||||
// the record.
|
||||
let published = Published {
|
||||
server: f.server.clone(),
|
||||
file: f.file.clone(),
|
||||
name: f.name.clone(),
|
||||
edit: f.edit.clone(),
|
||||
view: f.view.clone(),
|
||||
owner: None,
|
||||
expires: f.expires,
|
||||
};
|
||||
let share = Share { via: Via::Online, server: f.server.clone(), access: Access::Edit, links: f.shared.clone() };
|
||||
let id = crate::live::start_join(s, CMD, &f.edit, Some(published), Some(share))?;
|
||||
// Most recently opened first.
|
||||
let mut record = s.prefs.online_files.remove(i);
|
||||
record.opened = now();
|
||||
s.prefs.online_files.insert(0, record);
|
||||
s.online_changed();
|
||||
Ok(brief(id, "connecting", None, false))
|
||||
}
|
||||
|
||||
fn forget_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = named(s, "collab.forgetFile", p)?;
|
||||
let f = s.prefs.online_files.remove(i);
|
||||
s.online_changed();
|
||||
Ok(json!({"forgotten": true, "file": f.file, "server": f.server, "onServer": f.state != FileState::Gone}))
|
||||
}
|
||||
|
||||
/// Who manages `file` for `collab.manageFile`: the server and owner token given, else the
|
||||
/// recorded ones.
|
||||
pub(crate) fn manager(s: &Session, cmd: &str, p: &Value, file: &str) -> Result<(String, String)> {
|
||||
let given = |key: &str| string(p, key).map(|v| v.trim().to_string());
|
||||
match (given("server"), given("owner")) {
|
||||
(Some(server), Some(owner)) => Ok((server, owner)),
|
||||
(server, owner) => {
|
||||
let missing = if server.is_none() { "`server`" } else { "`owner`" };
|
||||
let i = s.online_named(cmd, file, server.as_deref()).map_err(|e| match e {
|
||||
crate::EngineError::BadParams { msg, .. } => bad(cmd, format!("{missing} is missing, and {msg}")),
|
||||
e => e,
|
||||
})?;
|
||||
let f = s.prefs.online_files.get(i).ok_or_else(|| bad(cmd, "no such file"))?;
|
||||
Ok((server.unwrap_or_else(|| f.server.clone()), owner.unwrap_or_else(|| f.owner.clone())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `collab.manageFile` renamed or removed `file` on `server`: the record follows.
|
||||
pub(crate) fn managed(s: &mut Session, server: &str, file: &str, renamed: Option<&str>) {
|
||||
let Some(i) = s.online_index(server, file) else { return };
|
||||
match renamed {
|
||||
Some(name) => {
|
||||
if let Some(f) = s.prefs.online_files.get_mut(i) {
|
||||
f.name = clean_name(name);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
s.prefs.online_files.remove(i);
|
||||
}
|
||||
}
|
||||
s.online_changed();
|
||||
}
|
||||
|
||||
/// `file` was removed from `server`: the sessions open on it end here (their copies stay open),
|
||||
/// before the server's goodbye reads as a failure.
|
||||
pub(crate) fn removed(s: &mut Session, server: &str, file: &str) {
|
||||
let open: Vec<u64> =
|
||||
s.live.iter().filter(|l| l.server_file().is_some_and(|(sv, f)| f == file && same_server(&sv, server))).map(|l| l.id).collect();
|
||||
for id in open {
|
||||
s.live.end(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// `collab.manageFile {action: "share" | "unshare"}`: give others new links and codes for `file`,
|
||||
/// or take away the ones they have. The file itself stays where it is, its owner's.
|
||||
pub(crate) fn set_shared(s: &mut Session, cmd: &str, server: &str, file: &str, owner: &str, on: bool) -> Result<Value> {
|
||||
let shared = s.online_index(server, file).and_then(|i| s.prefs.online_files.get(i)).and_then(|f| f.shared.clone());
|
||||
let result = |links: Option<&Shared>| {
|
||||
let (edit, view) = (links.map(|l| &l.edit), links.and_then(|l| l.view.as_ref()));
|
||||
let code = |l: Option<&SharedLink>| l.and_then(|l| l.code.as_deref()).map(code::grouped);
|
||||
json!({
|
||||
"shared": links.is_some(),
|
||||
"code": code(edit),
|
||||
"viewCode": code(view),
|
||||
"editLink": edit.map(|l| l.link.as_str()),
|
||||
"viewLink": view.map(|l| l.link.as_str()),
|
||||
})
|
||||
};
|
||||
// As asked already: nothing to tell the server.
|
||||
if on == shared.is_some() && (on || s.online_index(server, file).is_some()) {
|
||||
return Ok(result(shared.as_ref()));
|
||||
}
|
||||
if !on && shared.is_none() {
|
||||
return Err(bad(cmd, "the links others got aren't recorded on this computer: revoke them by id (action: links, then revoke)"));
|
||||
}
|
||||
let revoke: Vec<String> = shared.iter().flat_map(|l| [Some(&l.edit), l.view.as_ref()]).flatten().map(|l| l.id.clone()).collect();
|
||||
let req = NetRequest::Share { server: server.to_string(), file: file.to_string(), owner: owner.to_string(), revoke, create: on };
|
||||
let links = crate::live::share_answer(s, req)?.map(clean_shared);
|
||||
if on && links.is_none() {
|
||||
return Err(crate::EngineError::Other("the server made no links".into()));
|
||||
}
|
||||
s.online_shared(server, file, links.clone());
|
||||
Ok(result(links.as_ref()))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
"collab.files",
|
||||
"Your online files",
|
||||
"{refresh? (true: ask each server once what became of its files; default false: no network)} → {files: [{server, host, file (its id), name, created, opened (seconds since 1970), shared (others get in with a code now), code, viewCode (the join codes, while shared), state: unknown | online | gone (the server no longer has it: removed or expired), expires, session, document (the session and open document it is in now, or null), hasOwnerToken: true}], problems: [{server, host, state: offline | unsupported (a server older than this request), reason}], max}: the files you published or shared without a server key, most recently opened first, as recorded on this computer with the preferences. Owner tokens and your own links are never in the result",
|
||||
false,
|
||||
files
|
||||
),
|
||||
spec!(
|
||||
"collab.openFile",
|
||||
"Open online file",
|
||||
"{file (its id, from `collab.files`), server? (when files on several servers have that id)} → {session, status: \"connecting\", document, alreadyOpen, file, server, name}: joins the file as its owner in a tab of its own (`collab.peers` tells when it's live; `collab.share` and `collab.stop` there start and stop its sharing); a file that is open already comes to the front instead (alreadyOpen: true)",
|
||||
false,
|
||||
open_file
|
||||
),
|
||||
spec!(
|
||||
"collab.forgetFile",
|
||||
"Forget online file",
|
||||
"{file, server?} → {forgotten, file, server, onServer}: drops the file from the list on this computer only. The file stays on its server until it expires, and without its owner token it can't be managed from here again (`collab.manageFile {action: \"remove\"}` takes it off the server instead)",
|
||||
false,
|
||||
forget_file
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,581 @@
|
|||
//! Your online files against a scripted link: a file published without a key is recorded with
|
||||
//! its owner token, which never shows in a command's result, a summary or debug output; the list
|
||||
//! survives a restart (and junk in the preferences file); it is capped; a recorded file opens
|
||||
//! again as its owner, where sharing stops and starts; `collab.manageFile` takes its server and
|
||||
//! token from the record; one request per server says what became of the files (gone, renamed,
|
||||
//! unshared elsewhere), and a server that can't be reached or is too old leaves the list as it is;
|
||||
//! and what happens in a file that isn't in front names the file.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_collab::message::{Admin, AdminReply, ClientMsg, HostInfo, OwnedFile, Peer};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::live::{Access, Event, HostOptions, LiveLink, Presence, PublishOptions, Published, Role, SessionInfo, Shared, SharedLink, Status};
|
||||
use crate::online::{FileState, MAX_ONLINE_FILES, OnlineFile, ServerProblem};
|
||||
use crate::{Prefs, Session};
|
||||
|
||||
const SERVER: &str = "wss://share.example/";
|
||||
const OTHER: &str = "ws://127.0.0.1:47900/";
|
||||
const OWNER: &str = "OwnerSecretOwnerSecret";
|
||||
const EDIT_TOKEN: &str = "EditSecretEditSecret00";
|
||||
|
||||
/// What the scripted server keeps, what it was asked, and the events the link hands out next.
|
||||
#[derive(Default)]
|
||||
struct Script {
|
||||
joined: Vec<String>,
|
||||
published: Vec<PublishOptions>,
|
||||
/// (server, file, owner, request) of every hosting request.
|
||||
hosting: Vec<(String, String, String, Option<Admin>)>,
|
||||
/// (server, file, owner, revoke, create) of every share request.
|
||||
shares: Vec<(String, String, String, Vec<String>, bool)>,
|
||||
/// The files there, by id: `Admin::Owned` answers from it.
|
||||
files: Vec<(String, OwnedFile)>,
|
||||
/// Servers (by a part of their address) that can't be reached, or that can't read `Owned`.
|
||||
offline: Vec<&'static str>,
|
||||
old: Vec<&'static str>,
|
||||
left: Vec<u64>,
|
||||
events: Vec<Event>,
|
||||
session: u64,
|
||||
}
|
||||
|
||||
struct Link(Arc<Mutex<Script>>);
|
||||
|
||||
impl LiveLink for Link {
|
||||
fn host(&mut self, _: u64, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
s.joined.push(link.to_string());
|
||||
Ok(())
|
||||
}
|
||||
fn publish(&mut self, session: u64, opts: PublishOptions) -> Result<(), String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
s.published.push(opts);
|
||||
Ok(())
|
||||
}
|
||||
fn hosting(&mut self, server: &str, file: &str, owner: &str, req: Option<Admin>) -> Result<(HostInfo, Option<AdminReply>), String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.hosting.push((server.into(), file.into(), owner.into(), req.clone()));
|
||||
if s.offline.iter().any(|o| server.contains(o)) {
|
||||
return Err("no answer from the server (timed out)".into());
|
||||
}
|
||||
let reply = match req {
|
||||
Some(Admin::Owned { .. }) if s.old.iter().any(|o| server.contains(o)) => {
|
||||
return Err("the server refused: that message couldn't be read".into());
|
||||
}
|
||||
Some(Admin::Owned { files }) => {
|
||||
let known = |(file, owner): &(String, String)| s.files.iter().find(|(id, _)| id == file && owner == OWNER).map(|(_, f)| f.clone());
|
||||
AdminReply::Owned(files.iter().map(known).collect())
|
||||
}
|
||||
Some(Admin::Remove { file }) => {
|
||||
s.files.retain(|(id, _)| *id != file);
|
||||
AdminReply::Done
|
||||
}
|
||||
Some(Admin::Rename { .. }) => AdminReply::Done,
|
||||
_ => AdminReply::Failed { reason: "not in this test".into() },
|
||||
};
|
||||
Ok((HostInfo::default(), Some(reply)))
|
||||
}
|
||||
fn share(&mut self, server: &str, file: &str, owner: &str, revoke: &[String], create: bool) -> Result<Option<Shared>, String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
if s.offline.iter().any(|o| server.contains(o)) {
|
||||
return Err("no answer from the server (timed out)".into());
|
||||
}
|
||||
s.shares.push((server.into(), file.into(), owner.into(), revoke.to_vec(), create));
|
||||
let n = s.shares.iter().filter(|a| a.4).count();
|
||||
Ok(create.then(|| links(file, n)))
|
||||
}
|
||||
fn leave(&mut self, session: u64) {
|
||||
self.0.lock().unwrap().left.push(session);
|
||||
}
|
||||
fn presence(&mut self, _: u64, _: &Presence) {}
|
||||
fn send(&mut self, _: u64, _: ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<(u64, Event)> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
let session = s.session;
|
||||
std::mem::take(&mut s.events).into_iter().map(|e| (session, e)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The links a server makes for others the `n`th time `file` is shared, with their codes.
|
||||
fn links(file: &str, n: usize) -> Shared {
|
||||
let link = |what: &str, code: String| SharedLink {
|
||||
id: format!("{what}{n}"),
|
||||
link: format!("figcraft://join?server={SERVER}&file={file}&token=Shared{what}Shared{what}0{n}"),
|
||||
code: Some(code),
|
||||
};
|
||||
Shared { edit: link("edit", format!("NEWED1T0{n}")), view: Some(link("view", format!("NEWV1EW0{n}"))) }
|
||||
}
|
||||
|
||||
fn session() -> (Session, Arc<Mutex<Script>>) {
|
||||
with_prefs(Prefs::default())
|
||||
}
|
||||
|
||||
/// A run of the app with `prefs` loaded.
|
||||
fn with_prefs(prefs: Prefs) -> (Session, Arc<Mutex<Script>>) {
|
||||
let mut s = Session::new();
|
||||
s.prefs = prefs;
|
||||
let script = Arc::new(Mutex::new(Script::default()));
|
||||
s.live.install(Box::new(Link(script.clone())));
|
||||
(s, script)
|
||||
}
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn err(s: &mut Session, cmd: &str, p: Value) -> String {
|
||||
s.execute(cmd, &p).unwrap_err().to_string()
|
||||
}
|
||||
|
||||
fn edit_link(server: &str, file: &str) -> String {
|
||||
format!("figcraft://join?server={server}&file={file}&token={EDIT_TOKEN}")
|
||||
}
|
||||
|
||||
fn info(role: Role, name: &str, link: &str) -> SessionInfo {
|
||||
SessionInfo {
|
||||
role,
|
||||
me: 1,
|
||||
name: "Me".into(),
|
||||
color: "#0D99FF".into(),
|
||||
file_name: name.into(),
|
||||
pages: vec![],
|
||||
link: link.into(),
|
||||
url: String::new(),
|
||||
address: SERVER.into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
}
|
||||
}
|
||||
|
||||
/// The open file was shared online (`collab.share`) and reached the server as file `file`: its
|
||||
/// owner's links and token, the links for others, and the owner in it.
|
||||
fn shared_online(s: &mut Session, script: &Arc<Mutex<Script>>, file: &str) {
|
||||
ex(s, "pref.publishServer", json!({"url": SERVER}));
|
||||
ex(s, "collab.share", json!({}));
|
||||
let published = Published {
|
||||
server: "share.example".into(),
|
||||
file: file.into(),
|
||||
name: "Homepage".into(),
|
||||
edit: edit_link(SERVER, file),
|
||||
view: format!("figcraft://join?server={SERVER}&file={file}&token=ViewSecretViewSecret00"),
|
||||
owner: Some(OWNER.into()),
|
||||
expires: Some(1_900_000_000),
|
||||
};
|
||||
let welcome = Event::Welcome { info: info(Role::Guest, "Homepage", &published.edit), peers: vec![] };
|
||||
let mut sc = script.lock().unwrap();
|
||||
sc.files.push((
|
||||
file.into(),
|
||||
OwnedFile { name: "Homepage".into(), links: vec!["own-edit".into(), "own-view".into(), "edit0".into(), "view0".into()], expires: None },
|
||||
));
|
||||
sc.events.extend([Event::Published(published), Event::Shared(Ok(links(file, 0))), welcome]);
|
||||
drop(sc);
|
||||
s.live_poll();
|
||||
}
|
||||
|
||||
/// A record as the preferences file holds it.
|
||||
fn record(server: &str, file: &str, opened: u64) -> Value {
|
||||
json!({"server": server, "file": file, "name": format!("File {file}"), "owner": OWNER, "edit": edit_link(server, file), "view": "", "created": 1, "opened": opened})
|
||||
}
|
||||
|
||||
/// The preferences saved and loaded again (a restart).
|
||||
fn restarted(s: &Session) -> Prefs {
|
||||
serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn no_secrets(what: &str, text: &str) {
|
||||
for secret in [OWNER, EDIT_TOKEN] {
|
||||
assert!(!text.contains(secret), "{what} carries a secret: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_shared_online_is_recorded_and_its_token_never_shows() {
|
||||
let (mut s, script) = session();
|
||||
assert!(ex(&mut s, "collab.files", json!({}))["files"].as_array().unwrap().is_empty());
|
||||
shared_online(&mut s, &script, "f1");
|
||||
assert!(std::mem::take(&mut s.live.unsaved), "the app saves the preferences at once");
|
||||
let [f] = s.prefs.online_files.clone().try_into().unwrap_or_else(|v: Vec<OnlineFile>| panic!("{} recorded", v.len()));
|
||||
assert_eq!((f.server.as_str(), f.file.as_str(), f.name.as_str(), f.owner.as_str()), (SERVER, "f1", "Homepage", OWNER));
|
||||
assert_eq!((f.edit.as_str(), f.state, f.expires), (edit_link(SERVER, "f1").as_str(), FileState::Online, Some(1_900_000_000)));
|
||||
assert!(f.created > 1_700_000_000 && f.opened == f.created);
|
||||
assert_eq!(f.shared.as_ref().and_then(|l| l.edit.code.as_deref()), Some("NEWED1T00"), "what others join with is recorded too");
|
||||
// The list a script gets: the code to hand out, no token and none of the owner's links.
|
||||
let list = ex(&mut s, "collab.files", json!({}));
|
||||
let row = &list["files"][0];
|
||||
assert_eq!(
|
||||
(&row["host"], &row["file"], &row["name"], &row["shared"], &row["code"], &row["state"], &row["hasOwnerToken"], &row["document"]),
|
||||
(&json!("share.example"), &json!("f1"), &json!("Homepage"), &json!(true), &json!("NEW-ED1-T00"), &json!("online"), &json!(true), &json!(0))
|
||||
);
|
||||
assert_eq!(list["max"], MAX_ONLINE_FILES);
|
||||
no_secrets("collab.files", &list.to_string());
|
||||
// Debug output of the record and of everything that holds it.
|
||||
no_secrets("the record's debug output", &format!("{f:?}"));
|
||||
no_secrets("the preferences' debug output", &format!("{:?}", s.prefs));
|
||||
assert!(script.lock().unwrap().hosting.is_empty(), "listing asks no server");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_published_with_the_servers_key_is_the_operators_and_is_not_recorded() {
|
||||
let (mut s, script) = session();
|
||||
ex(&mut s, "collab.publish", json!({"server": "ws://127.0.0.1:47900/?key=ServerKeyServerKey"}));
|
||||
let published = Published {
|
||||
server: "127.0.0.1:47900".into(),
|
||||
file: "k1".into(),
|
||||
name: "Keyed".into(),
|
||||
edit: edit_link(OTHER, "k1"),
|
||||
view: String::new(),
|
||||
owner: None,
|
||||
expires: None,
|
||||
};
|
||||
script.lock().unwrap().events.push(Event::Published(published));
|
||||
s.live_poll();
|
||||
assert!(s.prefs.online_files.is_empty() && !s.live.unsaved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_list_survives_a_restart_and_junk_in_the_preferences_file() {
|
||||
let (mut s, script) = session();
|
||||
shared_online(&mut s, &script, "f1");
|
||||
let saved = serde_json::to_value(&s.prefs).unwrap();
|
||||
assert_eq!(saved["online_files"][0]["owner"], OWNER, "the token is kept on this computer, with the preferences");
|
||||
assert!(saved["online_files"][0].get("state").is_none(), "what the server said last isn't saved");
|
||||
let loaded = restarted(&s);
|
||||
let [f] = loaded.online_files.as_slice() else { panic!("{} loaded", loaded.online_files.len()) };
|
||||
assert_eq!((f.file.as_str(), f.owner.as_str(), f.state, f.shared.is_some()), ("f1", OWNER, FileState::Unknown, true));
|
||||
// Preferences from before there was a list, and ones without any, load and save as before.
|
||||
let old: Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
|
||||
assert!(old.online_files.is_empty() && serde_json::to_value(&old).unwrap().get("online_files").is_none());
|
||||
// Junk where the list should be never costs the other preferences.
|
||||
for junk in [json!("junk"), json!(7), json!(null), json!({"server": SERVER}), json!([1, "x", null, [], {}]), json!([[record(SERVER, "a", 1)]])] {
|
||||
let p: Prefs = serde_json::from_value(json!({"theme": "dark", "online_files": junk})).unwrap();
|
||||
assert!(p.theme == "dark" && p.online_files.is_empty(), "{junk}");
|
||||
}
|
||||
// Entries that can't be records are dropped one by one; the usable ones stay, cleaned.
|
||||
let with = |change: fn(&mut Value)| {
|
||||
let mut r = record(SERVER, "bad", 1);
|
||||
change(&mut r);
|
||||
r
|
||||
};
|
||||
// Usable: a key in the address is dropped, control characters leave the name, a view link
|
||||
// and share links that aren't links go, a code that isn't one goes.
|
||||
let mut usable = record("wss://share.example/?key=ServerKey", "ok1", 9);
|
||||
usable["name"] = json!(" Home\u{0}page\u{7} ");
|
||||
usable["view"] = json!("nope");
|
||||
usable["shared"] = json!({"edit": {"id": "e", "link": edit_link(SERVER, "ok1"), "code": "not-a-code!"}, "view": {"id": "v", "link": "nope"}});
|
||||
let list = json!([
|
||||
with(|r| r["server"] = json!("https://share.example/")),
|
||||
with(|r| r["server"] = json!(5)),
|
||||
with(|r| r["file"] = json!("../../etc")),
|
||||
with(|r| r["file"] = json!("")),
|
||||
with(|r| r["owner"] = json!("")),
|
||||
with(|r| r["owner"] = json!("two words")),
|
||||
with(|r| r["owner"] = json!("x".repeat(5000))),
|
||||
with(|r| r["edit"] = json!("not a link")),
|
||||
with(|r| r["opened"] = json!("yesterday")),
|
||||
with(|r| r["shared"] = json!("yes")),
|
||||
usable,
|
||||
record(SERVER, "ok2", 3),
|
||||
// The same file twice: once.
|
||||
record("wss://share.example:443", "ok2", 2),
|
||||
]);
|
||||
let p: Prefs = serde_json::from_value(json!({"online_files": list})).unwrap();
|
||||
let ids: Vec<&str> = p.online_files.iter().map(|f| f.file.as_str()).collect();
|
||||
assert_eq!(ids, ["ok1", "ok2"]);
|
||||
let f = &p.online_files[0];
|
||||
assert_eq!((f.server.as_str(), f.name.as_str(), f.view.as_str()), (SERVER, "Homepage", ""));
|
||||
let shared = f.shared.as_ref().unwrap();
|
||||
assert!(shared.edit.code.is_none() && shared.view.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_list_is_capped_and_the_file_opened_longest_ago_goes_first() {
|
||||
// More on disk than the cap: the first ones (most recently opened) are kept.
|
||||
let on_disk: Vec<Value> = (0..MAX_ONLINE_FILES + 50).map(|i| record(SERVER, &format!("f{i}"), 10_000 - i as u64)).collect();
|
||||
let p: Prefs = serde_json::from_value(json!({"online_files": on_disk})).unwrap();
|
||||
assert_eq!(p.online_files.len(), MAX_ONLINE_FILES);
|
||||
assert_eq!(p.online_files.last().map(|f| f.file.as_str()), Some(format!("f{}", MAX_ONLINE_FILES - 1).as_str()));
|
||||
// One more put online: it leads the list and the one opened longest ago is dropped.
|
||||
let (mut s, script) = with_prefs(p);
|
||||
shared_online(&mut s, &script, "new");
|
||||
assert_eq!(s.prefs.online_files.len(), MAX_ONLINE_FILES);
|
||||
assert_eq!(s.prefs.online_files.first().map(|f| f.file.as_str()), Some("new"));
|
||||
assert!(!s.prefs.online_files.iter().any(|f| f.file == format!("f{}", MAX_ONLINE_FILES - 1)));
|
||||
assert!(s.prefs.online_files.iter().any(|f| f.file == format!("f{}", MAX_ONLINE_FILES - 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recorded_file_opens_again_as_its_owner_after_a_restart() {
|
||||
let (mut s, script) = session();
|
||||
shared_online(&mut s, &script, "f1");
|
||||
// The app quits; the next run has only the preferences.
|
||||
let (mut s, script) = with_prefs(restarted(&s));
|
||||
assert!(s.live.iter().next().is_none());
|
||||
let r = ex(&mut s, "collab.openFile", json!({"file": "f1"}));
|
||||
assert_eq!(
|
||||
(&r["status"], &r["alreadyOpen"], &r["name"], &r["server"]),
|
||||
(&json!("connecting"), &json!(false), &json!("Homepage"), &json!(SERVER))
|
||||
);
|
||||
no_secrets("collab.openFile", &r.to_string());
|
||||
assert_eq!(script.lock().unwrap().joined, [edit_link(SERVER, "f1")], "joined with the owner's own edit link");
|
||||
assert!(s.prefs.online_files[0].opened >= s.prefs.online_files[0].created && std::mem::take(&mut s.live.unsaved));
|
||||
// The file arrives in a tab of its own, synced.
|
||||
let (_, _, sync) = crate::live_tests::serve(&crate::Session::new().doc().unwrap().clone());
|
||||
let welcome = Event::Welcome { info: info(Role::Guest, "Homepage", &edit_link(SERVER, "f1")), peers: vec![] };
|
||||
script.lock().unwrap().events.extend([welcome, sync]);
|
||||
s.live_poll();
|
||||
assert_eq!((s.docs.len(), s.active), (2, 1));
|
||||
let peers = ex(&mut s, "collab.peers", json!({}));
|
||||
assert_eq!(
|
||||
(&peers["status"], &peers["syncing"], &peers["code"], &peers["share"]["shared"]),
|
||||
(&json!("live"), &json!(true), &json!("NEW-ED1-T00"), &json!(true))
|
||||
);
|
||||
assert!(!peers.to_string().contains(OWNER), "the token stays in the record: {peers}");
|
||||
assert_eq!(s.online_of(s.docs[1].uid), Some((SERVER.to_string(), "f1".to_string())), "what a crash recovery reopens it by");
|
||||
assert_eq!(s.online_of(s.docs[0].uid), None);
|
||||
assert_eq!(ex(&mut s, "collab.files", json!({}))["files"][0]["document"], 1);
|
||||
// Stop sharing as the owner: the recorded token, the recorded links.
|
||||
let stopped = ex(&mut s, "collab.stop", json!({}));
|
||||
assert_eq!((&stopped["ended"], &stopped["shared"]), (&json!(false), &json!(false)));
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
let (server, file, owner, revoke, create) = sc.shares.last().unwrap();
|
||||
assert_eq!((server.as_str(), file.as_str(), owner.as_str(), *create), (SERVER, "f1", OWNER, false));
|
||||
assert_eq!(revoke, &["edit0", "view0"]);
|
||||
}
|
||||
assert!(s.prefs.online_files[0].shared.is_none(), "the record follows");
|
||||
assert_eq!(ex(&mut s, "collab.files", json!({}))["files"][0]["shared"], false);
|
||||
// Share again: new links and a new code, recorded.
|
||||
let again = ex(&mut s, "collab.share", json!({}));
|
||||
assert_eq!(again["code"], "NEW-ED1-T01");
|
||||
assert_eq!(s.prefs.online_files[0].shared.as_ref().map(|l| l.edit.id.as_str()), Some("edit1"));
|
||||
assert!(script.lock().unwrap().published.is_empty(), "nothing is published twice");
|
||||
// Opening it once more brings its tab to the front.
|
||||
ex(&mut s, "doc.switch", json!({"index": 0}));
|
||||
let r = ex(&mut s, "collab.openFile", json!({"file": "f1", "server": "wss://share.example"}));
|
||||
assert_eq!((&r["alreadyOpen"], &r["document"], s.active), (&json!(true), &json!(1), 1));
|
||||
assert_eq!(script.lock().unwrap().joined.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manage_file_takes_its_server_and_token_from_the_record() {
|
||||
let (mut s, script) = session();
|
||||
shared_online(&mut s, &script, "f1");
|
||||
let (mut s, script) = with_prefs(restarted(&s));
|
||||
// Unshare and share from the list, without opening the file.
|
||||
let r = ex(&mut s, "collab.manageFile", json!({"file": "f1", "action": "unshare"}));
|
||||
assert_eq!(r["shared"], false);
|
||||
assert_eq!(
|
||||
script.lock().unwrap().shares.last().map(|a| (a.2.clone(), a.3.clone(), a.4)),
|
||||
Some((OWNER.to_string(), vec!["edit0".into(), "view0".into()], false))
|
||||
);
|
||||
assert_eq!(ex(&mut s, "collab.manageFile", json!({"file": "f1", "action": "unshare"}))["shared"], false);
|
||||
assert_eq!(script.lock().unwrap().shares.len(), 1, "not shared already: the server isn't asked");
|
||||
let r = ex(&mut s, "collab.manageFile", json!({"file": "f1", "action": "share"}));
|
||||
assert_eq!((&r["shared"], &r["code"], &r["viewCode"]), (&json!(true), &json!("NEW-ED1-T01"), &json!("NEW-V1E-W01")));
|
||||
no_secrets("collab.manageFile share", &r.to_string());
|
||||
assert_eq!(ex(&mut s, "collab.manageFile", json!({"file": "f1", "action": "share"}))["code"], "NEW-ED1-T01", "shared already: the same code");
|
||||
assert_eq!(script.lock().unwrap().shares.len(), 2);
|
||||
assert!(restarted(&s).online_files[0].shared.is_some());
|
||||
// Rename: the record follows.
|
||||
assert_eq!(ex(&mut s, "collab.manageFile", json!({"file": "f1", "action": "rename", "name": "Landing"}))["done"], true);
|
||||
assert_eq!(s.prefs.online_files[0].name, "Landing");
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
let (server, file, owner, req) = sc.hosting.last().unwrap();
|
||||
assert_eq!((server.as_str(), file.as_str(), owner.as_str()), (SERVER, "f1", OWNER));
|
||||
assert!(matches!(req, Some(Admin::Rename { name, .. }) if name == "Landing"));
|
||||
}
|
||||
// A server or token given explicitly is used as given (a file that isn't recorded here).
|
||||
ex(&mut s, "collab.manageFile", json!({"server": OTHER, "file": "x9", "owner": "TheirToken", "action": "rename", "name": "N"}));
|
||||
assert_eq!(script.lock().unwrap().hosting.last().map(|h| (h.0.clone(), h.2.clone())), Some((OTHER.to_string(), "TheirToken".to_string())));
|
||||
// Neither given nor recorded.
|
||||
let e = err(&mut s, "collab.manageFile", json!({"file": "nope", "action": "remove"}));
|
||||
assert!(e.contains("`server` is missing") && e.contains("collab.files"), "{e}");
|
||||
let e = err(&mut s, "collab.manageFile", json!({"server": OTHER, "file": "f1", "action": "remove"}));
|
||||
assert!(e.contains("`owner` is missing"), "{e}");
|
||||
// Remove: off the server, out of the list, and a session open on it ends.
|
||||
ex(&mut s, "collab.openFile", json!({"file": "f1"}));
|
||||
script.lock().unwrap().events.push(Event::Welcome { info: info(Role::Guest, "Landing", &edit_link(SERVER, "f1")), peers: vec![] });
|
||||
s.live_poll();
|
||||
let session = s.live.iter().next().map(|l| l.id).unwrap();
|
||||
assert_eq!(ex(&mut s, "collab.manageFile", json!({"file": "f1", "action": "remove"}))["done"], true);
|
||||
assert!(s.prefs.online_files.is_empty() && script.lock().unwrap().files.is_empty());
|
||||
assert!(s.live.get(session).is_none() && script.lock().unwrap().left.contains(&session));
|
||||
assert!(err(&mut s, "collab.openFile", json!({"file": "f1"})).contains("no online file with that id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_server_is_asked_once_what_became_of_its_files() {
|
||||
let mut unshared = record(SERVER, "unshared", 6);
|
||||
unshared["shared"] = json!({"edit": {"id": "e9", "link": edit_link(SERVER, "unshared"), "code": "K7M2QX9TD"}});
|
||||
let list = json!([record(SERVER, "kept", 9), record(OTHER, "there", 8), record(SERVER, "gone", 7), unshared]);
|
||||
let prefs: Prefs = serde_json::from_value(json!({"online_files": list})).unwrap();
|
||||
let (mut s, script) = with_prefs(prefs);
|
||||
{
|
||||
let mut sc = script.lock().unwrap();
|
||||
let file = |name: &str, links: &[&str]| OwnedFile {
|
||||
name: name.into(),
|
||||
links: links.iter().map(|l| l.to_string()).collect(),
|
||||
expires: Some(1_900_000_000),
|
||||
};
|
||||
sc.files = vec![
|
||||
("kept".into(), file("Renamed elsewhere", &["a", "b"])),
|
||||
("there".into(), file("File there", &[])),
|
||||
("unshared".into(), file("File unshared", &["a"])),
|
||||
];
|
||||
}
|
||||
// Listing alone asks nothing.
|
||||
let list = ex(&mut s, "collab.files", json!({}));
|
||||
assert!(list["files"].as_array().unwrap().iter().all(|f| f["state"] == "unknown") && script.lock().unwrap().hosting.is_empty());
|
||||
let list = ex(&mut s, "collab.files", json!({"refresh": true}));
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
let asked: Vec<(&str, usize)> = sc
|
||||
.hosting
|
||||
.iter()
|
||||
.map(|(server, file, owner, req)| {
|
||||
assert!(file.is_empty() && owner.is_empty(), "no file is named when the connection opens");
|
||||
let Some(Admin::Owned { files }) = req else { panic!("asked {req:?}") };
|
||||
(server.as_str(), files.len())
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(asked, [(SERVER, 3), (OTHER, 1)], "one request per server, with all its files");
|
||||
}
|
||||
let by = |id: &str| list["files"].as_array().unwrap().iter().find(|f| f["file"] == id).cloned().unwrap();
|
||||
assert_eq!(
|
||||
(&by("kept")["state"], &by("kept")["name"], &by("kept")["expires"]),
|
||||
(&json!("online"), &json!("Renamed elsewhere"), &json!(1_900_000_000u64))
|
||||
);
|
||||
assert_eq!(by("there")["state"], "online");
|
||||
assert_eq!((&by("gone")["state"], &by("gone")["shared"]), (&json!("gone"), &json!(false)));
|
||||
assert_eq!(
|
||||
(&by("unshared")["state"], &by("unshared")["shared"], &by("unshared")["code"]),
|
||||
(&json!("online"), &json!(false), &Value::Null),
|
||||
"its link was revoked elsewhere"
|
||||
);
|
||||
assert!(list["problems"].as_array().unwrap().is_empty());
|
||||
no_secrets("collab.files {refresh}", &list.to_string());
|
||||
// A file that is gone can't be opened; it can be cleared from the list.
|
||||
assert!(err(&mut s, "collab.openFile", json!({"file": "gone"})).contains("no longer on its server"));
|
||||
let r = ex(&mut s, "collab.forgetFile", json!({"file": "gone"}));
|
||||
assert_eq!((&r["forgotten"], &r["onServer"]), (&json!(true), &json!(false)));
|
||||
// Forgetting one that is there says the file stays, and asks no server.
|
||||
let before = script.lock().unwrap().hosting.len();
|
||||
let r = ex(&mut s, "collab.forgetFile", json!({"file": "there"}));
|
||||
assert_eq!((&r["forgotten"], &r["onServer"], &r["server"]), (&json!(true), &json!(true), &json!(OTHER)));
|
||||
assert_eq!(script.lock().unwrap().hosting.len(), before);
|
||||
assert_eq!(s.prefs.online_files.len(), 2);
|
||||
assert!(err(&mut s, "collab.forgetFile", json!({"file": "there"})).contains("no online file with that id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_cant_be_reached_or_is_too_old_leaves_the_list_as_it_is() {
|
||||
let prefs: Prefs = serde_json::from_value(json!({"online_files": [record(SERVER, "a", 9), record(OTHER, "b", 8)]})).unwrap();
|
||||
let (mut s, script) = with_prefs(prefs);
|
||||
script.lock().unwrap().offline = vec!["share.example"];
|
||||
script.lock().unwrap().old = vec!["127.0.0.1"];
|
||||
let list = ex(&mut s, "collab.files", json!({"refresh": true}));
|
||||
assert_eq!(list["files"].as_array().unwrap().len(), 2, "what this computer remembers still shows");
|
||||
assert!(list["files"].as_array().unwrap().iter().all(|f| f["state"] == "unknown"));
|
||||
let problems = list["problems"].as_array().unwrap();
|
||||
let of = |host: &str| problems.iter().find(|p| p["host"] == host).cloned().unwrap();
|
||||
assert_eq!(of("share.example")["state"], "offline");
|
||||
assert!(of("share.example")["reason"].as_str().unwrap().contains("no answer"));
|
||||
assert_eq!(of("127.0.0.1:47900")["state"], "unsupported");
|
||||
assert_eq!(s.live.servers.get(OTHER), Some(&ServerProblem::Unsupported));
|
||||
// Sharing while offline changes nothing and says why.
|
||||
let e = err(&mut s, "collab.manageFile", json!({"file": "a", "action": "share"}));
|
||||
assert!(e.contains("no answer") && s.prefs.online_files[0].shared.is_none(), "{e}");
|
||||
// Back online: the problem clears.
|
||||
script.lock().unwrap().offline.clear();
|
||||
script.lock().unwrap().files.push(("a".into(), OwnedFile::default()));
|
||||
let list = ex(&mut s, "collab.files", json!({"refresh": true}));
|
||||
assert_eq!(list["problems"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(list["files"][0]["state"], "online");
|
||||
// Without the network (the command line) a refresh says so; the list still reads.
|
||||
let mut headless = Session::new();
|
||||
headless.prefs = s.prefs.clone();
|
||||
assert!(err(&mut headless, "collab.files", json!({"refresh": true})).contains("desktop app"));
|
||||
assert_eq!(ex(&mut headless, "collab.files", json!({}))["files"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_with_the_same_id_on_two_servers_need_the_server_named() {
|
||||
let prefs: Prefs = serde_json::from_value(json!({"online_files": [record(SERVER, "same", 9), record(OTHER, "same", 8)]})).unwrap();
|
||||
let (mut s, script) = with_prefs(prefs);
|
||||
assert!(err(&mut s, "collab.openFile", json!({"file": "same"})).contains("say which with `server`"));
|
||||
ex(&mut s, "collab.openFile", json!({"file": "same", "server": OTHER}));
|
||||
assert_eq!(script.lock().unwrap().joined, [edit_link(OTHER, "same")]);
|
||||
assert_eq!(s.prefs.online_files[0].server, OTHER, "most recently opened first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_happens_in_a_file_that_isnt_in_front_names_the_file() {
|
||||
let (mut s, script) = session();
|
||||
ex(&mut s, "file.rename", json!({"name": "Homepage"}));
|
||||
shared_online(&mut s, &script, "f1");
|
||||
let peer = |id, name: &str| Peer { client_id: id, name: name.into(), color: "#9747FF".into() };
|
||||
// In front: as before.
|
||||
script.lock().unwrap().events.push(Event::PeerJoined(peer(2, "Ada")));
|
||||
s.live_poll();
|
||||
assert_eq!(s.message.take().as_deref(), Some("Ada joined"));
|
||||
// Another file in front.
|
||||
ex(&mut s, "file.new", json!({}));
|
||||
script.lock().unwrap().events.push(Event::PeerJoined(peer(3, "Hana")));
|
||||
s.live_poll();
|
||||
assert_eq!(s.message.take().as_deref(), Some("Hana joined Homepage"));
|
||||
script.lock().unwrap().events.push(Event::PeerLeft(2));
|
||||
s.live_poll();
|
||||
assert_eq!(s.message.take().as_deref(), Some("Ada left Homepage"));
|
||||
script.lock().unwrap().events.push(Event::Ended { reason: "The server stopped".into() });
|
||||
s.live_poll();
|
||||
assert_eq!(s.message.take().as_deref(), Some("Homepage: The server stopped"));
|
||||
assert_eq!(s.live.iter().filter(|l| l.status == Status::Live).count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_commands_survive_junk_with_files_recorded() {
|
||||
let prefs: Prefs = serde_json::from_value(json!({"online_files": [record(SERVER, "a", 9), record(OTHER, "b", 8)]})).unwrap();
|
||||
let junk = [
|
||||
json!({"file": [1], "server": 7, "refresh": "maybe", "action": "share", "name": {"x": 1}}),
|
||||
json!({"file": "../x", "server": "wss://\u{0}", "refresh": true, "action": "unshare", "name": 5}),
|
||||
json!({"file": "a", "server": "\u{1F4A5}".repeat(64), "refresh": 1e308, "action": "rename", "name": ""}),
|
||||
json!({"file": "a", "server": null, "owner": [], "refresh": null, "action": 3}),
|
||||
json!({"file": "", "server": "", "owner": "", "action": ""}),
|
||||
json!([1, 2]),
|
||||
json!("a"),
|
||||
];
|
||||
for cmd in ["collab.files", "collab.openFile", "collab.forgetFile", "collab.manageFile"] {
|
||||
for j in &junk {
|
||||
let (mut s, _) = with_prefs(prefs.clone());
|
||||
if let Err(crate::EngineError::Internal { cmd, msg }) = s.execute(cmd, j) {
|
||||
panic!("{cmd} panicked on {j}: {msg}");
|
||||
}
|
||||
// Nothing a junk call returns or leaves behind names a secret.
|
||||
if let Ok(v) = s.execute("collab.files", &json!({})) {
|
||||
no_secrets(cmd, &v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The record type itself: what `Debug` prints.
|
||||
#[test]
|
||||
fn debug_output_of_a_record_leaves_the_secrets_out() {
|
||||
let f = OnlineFile {
|
||||
server: SERVER.into(),
|
||||
file: "f".into(),
|
||||
name: "N".into(),
|
||||
owner: OWNER.into(),
|
||||
edit: edit_link(SERVER, "f"),
|
||||
..OnlineFile::default()
|
||||
};
|
||||
let text = format!("{f:?} {:#?}", vec![f.clone()]);
|
||||
no_secrets("debug", &text);
|
||||
assert!(text.contains("share.example") && text.contains(".."), "{text}");
|
||||
}
|
||||
|
|
@ -279,6 +279,9 @@ fn every_command_survives_junk_params() {
|
|||
json!({"via": 7, "access": [1], "code": {"x": 1}, "link": -1, "server": 5, "port": -1, "bind": 3, "name": [1]}),
|
||||
json!({"via": "carrier pigeon", "access": "admin", "code": "K7M-2QX-9T\u{0}", "server": "wss://\u{0}", "port": 1e308, "bind": "::::"}),
|
||||
json!({"via": "local", "access": "view", "code": "\u{1F4A5}".repeat(64), "port": 70000, "bind": "lan"}),
|
||||
// Online files: ids, servers, refreshes, actions and names as junk.
|
||||
json!({"file": [1], "server": 7, "refresh": "maybe", "action": "share", "name": {"x": 1}}),
|
||||
json!({"file": "../x", "server": "wss://\u{0}", "refresh": true, "action": "unshare", "name": 5}),
|
||||
// Rotation origins and placing with a tool as junk.
|
||||
json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}),
|
||||
json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}),
|
||||
|
|
|
|||
|
|
@ -13,14 +13,17 @@ status = "draft — needs native review"
|
|||
"(no longer published)" = "(nicht mehr veröffentlicht)"
|
||||
"({n} warnings: some entries were skipped)" = { one = "({n} Warnung: einige Einträge wurden übersprungen)", other = "({n} Warnungen: einige Einträge wurden übersprungen)" }
|
||||
"+ Create variable" = "+ Variable erstellen"
|
||||
".fig (experimental)" = ".fig (experimentell)"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} Commits, +{added} / −{deleted} Zeilen (Δ {delta}), +{bin_added} / −{bin_deleted} Binärdateien, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Eine Sammlung dieser Datei mit diesen Modi und Variablen: Überschreiben Sie ihre Werte je Modus und fügen Sie eigene Variablen hinzu"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Eine Feder läuft, bis sie zur Ruhe kommt: Masse, Steifigkeit und Dämpfung bestimmen die Dauer"
|
||||
"About" = "Über"
|
||||
"About FigCraft" = "Über FigCraft"
|
||||
"About this server" = "Über diesen Server"
|
||||
"Action" = "Aktion"
|
||||
"Actions" = "Aktionen"
|
||||
"Actions for {name}" = "Aktionen für {name}"
|
||||
"Actions…" = "Aktionen…"
|
||||
"Active" = "Aktiv"
|
||||
"Actual size (100%)" = "Originalgröße (100 %)"
|
||||
|
|
@ -62,6 +65,7 @@ status = "draft — needs native review"
|
|||
"Added the library \"{name}\"" = "Bibliothek „{name}“ hinzugefügt"
|
||||
"Adjust" = "Anpassen"
|
||||
"Adjustment presets" = "Vorgaben"
|
||||
"Advanced" = "Erweitert"
|
||||
"Advanced stroke" = "Erweiterte Kontur"
|
||||
"After delay" = "Nach Verzögerung"
|
||||
"After delay {ms}ms" = "Nach {ms} ms"
|
||||
|
|
@ -85,14 +89,19 @@ status = "draft — needs native review"
|
|||
"All variables" = "Alle Variablen"
|
||||
"Alpha" = "Alpha"
|
||||
"Alt+drag" = "Alt+Ziehen"
|
||||
"An invite link that opens FigCraft and joins" = "Ein Einladungslink, der FigCraft öffnet und beitritt"
|
||||
"Anchor" = "Anker"
|
||||
"Android" = "Android"
|
||||
"Angular" = "Winkel"
|
||||
"Animation" = "Animation"
|
||||
"Annotations" = "Anmerkungen"
|
||||
"Annotations shown on the canvas" = "Auf der Arbeitsfläche angezeigte Anmerkungen"
|
||||
"Another server, or one that needs its key. The usual server is set in Preferences." = "Ein anderer Server oder einer, der seinen Schlüssel braucht. Der übliche Server wird in den Einstellungen festgelegt."
|
||||
"Any status" = "Beliebiger Status"
|
||||
"Anyone on your network with this code can edit the file, while this app stays open." = "Jede Person in Ihrem Netzwerk mit diesem Code kann die Datei bearbeiten, solange diese App geöffnet bleibt."
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Jede Person mit dem Bearbeitungslink kann die Datei ändern; der Ansichtslink erlaubt nur das Ansehen. Die Links enthalten ihre Geheimnisse: Teilen Sie sie wie Passwörter. `figcraft-server links` erzeugt weitere und widerruft sie."
|
||||
"Anyone with this code can edit the file." = "Jede Person mit diesem Code kann die Datei bearbeiten."
|
||||
"Anyone with this code can look at the file, not change it." = "Jede Person mit diesem Code kann die Datei ansehen, aber nicht ändern."
|
||||
"Appearance" = "Darstellung"
|
||||
"Apply a component property" = "Komponenteneigenschaft anwenden"
|
||||
"Apply variable" = "Variable anwenden"
|
||||
|
|
@ -107,6 +116,7 @@ status = "draft — needs native review"
|
|||
"Arrange" = "Anordnen"
|
||||
"Arrow" = "Pfeil"
|
||||
"As typed" = "Wie eingegeben"
|
||||
"Asking the server…" = "Server wird gefragt…"
|
||||
"Assets" = "Assets"
|
||||
"Auto" = "Auto"
|
||||
"Auto (space around)" = "Auto (Abstand ringsum)"
|
||||
|
|
@ -119,6 +129,7 @@ status = "draft — needs native review"
|
|||
"Auto layout" = "Auto-Layout"
|
||||
"Auto rows" = "Automatische Zeilen"
|
||||
"Auto width" = "Automatische Breite"
|
||||
"Back to the FigCraft server" = "Zurück zum FigCraft-Server"
|
||||
"Back to the font's value" = "Zurück zum Wert der Schrift"
|
||||
"Back" = "Zurück"
|
||||
"Back to Ready for dev" = "Zurück zu „Bereit für Entwicklung“"
|
||||
|
|
@ -174,6 +185,10 @@ status = "draft — needs native review"
|
|||
"C or Shift+C" = "C oder Umschalt+C"
|
||||
"C or ⇧C" = "C oder ⇧C"
|
||||
"CMYK colour" = "CMYK-Farbe"
|
||||
"Can view" = "Kann ansehen"
|
||||
"Checking the server…" = "Server wird geprüft…"
|
||||
"Clear from this list" = "Aus dieser Liste entfernen"
|
||||
"Code or invite link" = "Code oder Einladungslink"
|
||||
"Color profile: {name}" = "Farbprofil: {name}"
|
||||
"CSS custom properties…" = "Benutzerdefinierte CSS-Eigenschaften…"
|
||||
"Can edit" = "Kann bearbeiten"
|
||||
|
|
@ -280,11 +295,13 @@ status = "draft — needs native review"
|
|||
"Copy as CSS" = "Als CSS kopieren"
|
||||
"Copy as PNG" = "Als PNG kopieren"
|
||||
"Copy as SVG" = "Als SVG kopieren"
|
||||
"Copy code" = "Code kopieren"
|
||||
"Copy link" = "Link kopieren"
|
||||
"Copy the {label}" = "{label} kopieren"
|
||||
"Copy {value}" = "{value} kopieren"
|
||||
"Corner radius" = "Eckenradius"
|
||||
"Corner smoothing" = "Eckenglättung"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat."
|
||||
"Count" = "Anzahl"
|
||||
"Create" = "Erstellen"
|
||||
"Create a variable collection first" = "Erstellen Sie zuerst eine Variablensammlung"
|
||||
|
|
@ -401,6 +418,7 @@ status = "draft — needs native review"
|
|||
"Empty" = "Leer"
|
||||
"End" = "Ende"
|
||||
"End point" = "Endpunkt"
|
||||
"Ends the session; everyone keeps their copy" = "Beendet die Sitzung; alle behalten ihre Kopie"
|
||||
"Enter" = "Eingabe"
|
||||
"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Geben Sie die Adresse eines figcraft-server ein und drücken Sie auf Durchsuchen, um die angebotenen Plugins zu sehen."
|
||||
"Enter to save · Esc to cancel" = "Eingabe zum Speichern · Esc zum Abbrechen"
|
||||
|
|
@ -426,6 +444,7 @@ status = "draft — needs native review"
|
|||
"Export {n} layers" = { one = "{n} Ebene exportieren", other = "{n} Ebenen exportieren" }
|
||||
"Exported {file}" = "Exportiert: {file}"
|
||||
"Exported {name}" = "{name} exportiert"
|
||||
"Exported {name}; {n} kinds of content were left out" = { one = "{name} exportiert; {n} Art von Inhalt wurde ausgelassen", other = "{name} exportiert; {n} Arten von Inhalt wurden ausgelassen" }
|
||||
"Exported {n} files" = { one = "{n} Datei exportiert", other = "{n} Dateien exportiert" }
|
||||
"Export…" = "Exportieren…"
|
||||
"Expose properties to instances" = "Eigenschaften für Instanzen freigeben"
|
||||
|
|
@ -440,6 +459,9 @@ status = "draft — needs native review"
|
|||
"Fetching from the server…" = "Wird vom Server abgerufen…"
|
||||
"File" = "Datei"
|
||||
"File name" = "Dateiname"
|
||||
"File published" = "Datei veröffentlicht"
|
||||
"Files aren't end-to-end encrypted: the server's operator can read them." = "Dateien sind nicht Ende-zu-Ende-verschlüsselt: Der Betreiber des Servers kann sie lesen."
|
||||
"Files nobody opens for {n} days are deleted." = { one = "Dateien, die {n} Tag lang niemand öffnet, werden gelöscht.", other = "Dateien, die {n} Tage lang niemand öffnet, werden gelöscht." }
|
||||
"Fill" = "Füllung"
|
||||
"Fill (fr)" = "Füllen (fr)"
|
||||
"Fill container" = "Container füllen"
|
||||
|
|
@ -482,6 +504,7 @@ status = "draft — needs native review"
|
|||
"Font not installed" = "Schrift nicht installiert"
|
||||
"Font size" = "Schriftgröße"
|
||||
"Font style" = "Schriftschnitt"
|
||||
"Forget on this computer" = "Auf diesem Computer vergessen"
|
||||
"Fractions" = "Brüche"
|
||||
"Frame" = "Rahmen"
|
||||
"Frame selection" = "Auswahl einrahmen"
|
||||
|
|
@ -495,6 +518,7 @@ status = "draft — needs native review"
|
|||
"Gap between columns" = "Abstand zwischen Spalten"
|
||||
"Gap between items" = "Abstand zwischen Elementen"
|
||||
"Gap between rows" = "Abstand zwischen Zeilen"
|
||||
"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Geben Sie die Adresse eines öffentlich zugänglichen Servers an oder die Adresse eines Servers mit seinem Schlüssel (`figcraft-server key` gibt sie aus)."
|
||||
"Glass" = "Glas"
|
||||
"Go to main component" = "Zur Hauptkomponente"
|
||||
"Go to {name}" = "Zu {name}"
|
||||
|
|
@ -518,6 +542,7 @@ status = "draft — needs native review"
|
|||
"Help" = "Hilfe"
|
||||
"Help and resources" = "Hilfe und Ressourcen"
|
||||
"Help page" = "Hilfeseite"
|
||||
"Here: {names}" = "Hier: {names}"
|
||||
"Hidden" = "Ausgeblendet"
|
||||
"Hide contents" = "Inhalt ausblenden"
|
||||
"Hide from publishing" = "Nicht veröffentlichen"
|
||||
|
|
@ -537,6 +562,7 @@ status = "draft — needs native review"
|
|||
"Horizontal hex" = "Horizontales Sechseck"
|
||||
"Horizontal padding" = "Horizontaler Innenabstand"
|
||||
"Horizontal spacing" = "Horizontaler Abstand"
|
||||
"Hosted by {operator}" = "Gehostet von {operator}"
|
||||
"Hover a layer to see its properties" = "Fahren Sie über eine Ebene, um ihre Eigenschaften zu sehen"
|
||||
"Hover other layers to measure the distance" = "Fahren Sie über andere Ebenen, um den Abstand zu messen"
|
||||
"Hue" = "Farbton"
|
||||
|
|
@ -587,8 +613,11 @@ status = "draft — needs native review"
|
|||
"Invite link copied" = "Einladungslink kopiert"
|
||||
"It asks permission to:" = "Es möchte:"
|
||||
"Join" = "Beitreten"
|
||||
"Join code copied" = "Beitrittscode kopiert"
|
||||
"Join code or invite link" = "Beitrittscode oder Einladungslink"
|
||||
"Join live session" = "Live-Sitzung beitreten"
|
||||
"Join us on Discord" = "Besuchen Sie uns auf Discord"
|
||||
"Join with a code" = "Mit einem Code beitreten"
|
||||
"Joined" = "Beigetreten"
|
||||
"Joined {file}" = "{file} beigetreten"
|
||||
"Joining…" = "Beitritt…"
|
||||
|
|
@ -651,6 +680,7 @@ status = "draft — needs native review"
|
|||
"Live session" = "Live-Sitzung"
|
||||
"Live · {people}" = "Live · {people}"
|
||||
"Local components" = "Lokale Komponenten"
|
||||
"Local network" = "Lokales Netzwerk"
|
||||
"Local styles" = "Lokale Stile"
|
||||
"Local variables" = "Lokale Variablen"
|
||||
"Lock/Unlock" = "Sperren/Entsperren"
|
||||
|
|
@ -740,6 +770,7 @@ status = "draft — needs native review"
|
|||
"No conflicts." = "Keine Konflikte."
|
||||
"No contributor data was built into this copy." = "Diese Kopie enthält keine Daten zu Mitwirkenden."
|
||||
"No fonts match" = "Keine passenden Schriften"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Hier gibt es keinen Beitrittscode (der Server ist älter als Beitrittscodes, oder diese Adresse passt in keinen): Teilen Sie stattdessen den Link."
|
||||
"No layer changes to show." = "Keine Ebenenänderungen vorhanden."
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Noch keine Bibliotheken. Fügen Sie eine als Bibliothek veröffentlichte .figcraft-Datei hinzu oder fügen Sie den Link einer Teambibliothek ein."
|
||||
"No list" = "Keine Liste"
|
||||
|
|
@ -778,9 +809,11 @@ status = "draft — needs native review"
|
|||
"Not in this font" = "Nicht in dieser Schrift"
|
||||
"Not properties, kept as written: {names}" = "Keine Eigenschaften, unverändert übernommen: {names}"
|
||||
"Not read yet" = "Noch nicht gelesen"
|
||||
"Not shared" = "Nicht geteilt"
|
||||
"Note {n}" = "Notiz {n}"
|
||||
"Nothing changed since this was marked ready for dev." = "Seit der Markierung als bereit für die Entwicklung hat sich nichts geändert."
|
||||
"Nothing here yet. Set a frame's status to list it." = "Noch nichts hier. Legen Sie den Status eines Rahmens fest, um ihn aufzulisten."
|
||||
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Noch nichts online. Stellen Sie eine Datei über „Teilen“ online, dann erscheint sie hier."
|
||||
"Nothing to export" = "Nichts zu exportieren"
|
||||
"Now" = "Jetzt"
|
||||
"Nudge (Shift: big nudge)" = "Verschieben (Umschalt: großer Schritt)"
|
||||
|
|
@ -797,6 +830,9 @@ status = "draft — needs native review"
|
|||
"On click" = "Bei Klick"
|
||||
"On drag" = "Beim Ziehen"
|
||||
"On this page" = "Auf dieser Seite"
|
||||
"Online" = "Online"
|
||||
"Online files" = "Online-Dateien"
|
||||
"Online sharing is off: files are shared on the local network only." = "Online-Teilen ist aus: Dateien werden nur im lokalen Netzwerk geteilt."
|
||||
"Only the first changed layers are listed." = "Nur die ersten geänderten Ebenen werden aufgeführt."
|
||||
"Only the first {n} changed layers are listed." = "Nur die ersten {n} geänderten Ebenen werden aufgeführt."
|
||||
"Only your threads" = "Nur Ihre Threads"
|
||||
|
|
@ -820,6 +856,9 @@ status = "draft — needs native review"
|
|||
"Open|comments" = "Offen"
|
||||
"Options" = "Optionen"
|
||||
"Orange" = "Orange"
|
||||
"Other apps may not open every .fig file exported here." = "Andere Apps öffnen möglicherweise nicht jede hier exportierte .fig-Datei."
|
||||
"Others lose access and keep their copy; the file stays on the server for you" = "Die anderen verlieren den Zugriff und behalten ihre Kopie; die Datei bleibt für Sie auf dem Server"
|
||||
"Others type this under Share ▸ Join with a code" = "Die anderen geben ihn unter Teilen ▸ Mit einem Code beitreten ein"
|
||||
"Outline stroke" = "Kontur in Pfad umwandeln"
|
||||
"Outline text" = "Text in Pfade umwandeln"
|
||||
"Outlines" = "Umrisse"
|
||||
|
|
@ -829,6 +868,8 @@ status = "draft — needs native review"
|
|||
"Overlay position" = "Overlay-Position"
|
||||
"Overridden here (the collection it extends has another value)" = "Hier überschrieben (die erweiterte Sammlung hat einen anderen Wert)"
|
||||
"Overrides" = "Überschreibungen"
|
||||
"Owner token" = "Eigentümer-Token"
|
||||
"Owner token copied" = "Eigentümer-Token kopiert"
|
||||
"PDF print options" = "PDF-Druckoptionen"
|
||||
"PDF print settings" = "PDF-Druckeinstellungen"
|
||||
"Padding" = "Innenabstand"
|
||||
|
|
@ -855,6 +896,8 @@ status = "draft — needs native review"
|
|||
"Pen" = "Zeichenstift"
|
||||
"Pencil" = "Bleistift"
|
||||
"People in this file" = "Personen in dieser Datei"
|
||||
"People join from anywhere with a code. The file is kept on {server} while you share it." = "Andere treten von überall mit einem Code bei. Die Datei liegt auf {server}, solange Sie sie teilen."
|
||||
"People on your network join with a code. Nothing goes through a server." = "Personen in Ihrem Netzwerk treten mit einem Code bei. Nichts läuft über einen Server."
|
||||
"Phone" = "Telefon"
|
||||
"Pick color" = "Farbe aufnehmen"
|
||||
"Pick color (I)" = "Farbe aufnehmen (I)"
|
||||
|
|
@ -875,6 +918,7 @@ status = "draft — needs native review"
|
|||
"Points" = "Punkte"
|
||||
"Polygon" = "Polygon"
|
||||
"Polygons" = "Polygone"
|
||||
"Port" = "Port"
|
||||
"Position" = "Position"
|
||||
"Post" = "Senden"
|
||||
"Preferences" = "Einstellungen"
|
||||
|
|
@ -903,12 +947,14 @@ status = "draft — needs native review"
|
|||
"Prototype settings" = "Prototyp-Einstellungen"
|
||||
"Publish" = "Veröffentlichen"
|
||||
"Publish changes" = "Änderungen veröffentlichen"
|
||||
"Publish to another server…" = "Auf anderem Server veröffentlichen…"
|
||||
"Publish to server" = "Auf Server veröffentlichen"
|
||||
"Published version {v}" = "Version {v} veröffentlicht"
|
||||
"Published version {v} to the server" = "Version {v} auf dem Server veröffentlicht"
|
||||
"Published version {v}: save the file so other files can use it" = "Version {v} veröffentlicht: Speichern Sie die Datei, damit andere Dateien sie verwenden können"
|
||||
"Published · {people}" = "Veröffentlicht · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Zum Veröffentlichen auf einem Server ist die Desktop-App nötig; im Browser können Sie Dateien eines Servers beitreten"
|
||||
"Publishing with the server's key." = "Veröffentlichung mit dem Schlüssel des Servers."
|
||||
"Publishing…" = "Wird veröffentlicht…"
|
||||
"Push" = "Schieben"
|
||||
"Push changes to main component" = "Änderungen an Hauptkomponente übertragen"
|
||||
|
|
@ -936,6 +982,8 @@ status = "draft — needs native review"
|
|||
"Remove auto layout" = "Auto-Layout entfernen"
|
||||
"Remove dev resource" = "Entwicklerressource entfernen"
|
||||
"Remove export setting" = "Exporteinstellung entfernen"
|
||||
"Remove from server" = "Vom Server entfernen"
|
||||
"Remove from server…" = "Vom Server entfernen…"
|
||||
"Remove guide" = "Hilfslinie entfernen"
|
||||
"Remove interaction" = "Interaktion entfernen"
|
||||
"Remove library from this file" = "Bibliothek aus dieser Datei entfernen"
|
||||
|
|
@ -949,7 +997,10 @@ status = "draft — needs native review"
|
|||
"Remove text from path" = "Text vom Pfad lösen"
|
||||
"Remove the else and its actions" = "„Sonst“ und seine Aktionen entfernen"
|
||||
"Remove this condition and its actions" = "Diese Bedingung und ihre Aktionen entfernen"
|
||||
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Diese Datei vom Server entfernen? Sie kann von dort nicht mehr geteilt oder geöffnet werden. Ihre Kopie bleibt hier geöffnet."
|
||||
"Remove {name} from {server}?" = "{name} von {server} entfernen?"
|
||||
"Removed" = "Entfernt"
|
||||
"Removed from the server" = "Vom Server entfernt"
|
||||
"Removed since it was marked" = "Seit der Markierung entfernt"
|
||||
"Rename" = "Umbenennen"
|
||||
"Rename duplicated layers" = "Duplizierte Ebenen umbenennen"
|
||||
|
|
@ -957,6 +1008,8 @@ status = "draft — needs native review"
|
|||
"Rename group" = "Gruppe umbenennen"
|
||||
"Rename layers" = "Ebenen umbenennen"
|
||||
"Rename mode" = "Modus umbenennen"
|
||||
"Rename on the server" = "Auf dem Server umbenennen"
|
||||
"Rename on the server…" = "Auf dem Server umbenennen…"
|
||||
"Rename page" = "Seite umbenennen"
|
||||
"Rename to" = "Umbenennen in"
|
||||
"Replace all" = "Alle ersetzen"
|
||||
|
|
@ -964,6 +1017,8 @@ status = "draft — needs native review"
|
|||
"Replace with" = "Ersetzen durch"
|
||||
"Replace with a new interaction" = "Durch eine neue Interaktion ersetzen"
|
||||
"Reply" = "Antworten"
|
||||
"Report" = "Meldung"
|
||||
"Report this file…" = "Diese Datei melden…"
|
||||
"Reset" = "Zurücksetzen"
|
||||
"Reset adjustments" = "Anpassungen zurücksetzen"
|
||||
"Reset all changes" = "Alle Änderungen zurücksetzen"
|
||||
|
|
@ -1053,9 +1108,11 @@ status = "draft — needs native review"
|
|||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Auswahlrahmen, {w} × {h} bei {x}, {y}: Pfeiltasten verschieben ihn, Umschalt+Pfeiltasten oder {cmd}+Pfeiltasten ändern die Größe, Eingabe wählt aus, Esc bricht ab"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Auswahlrahmen: Pfeiltasten verschieben ihn, Umschalt+Pfeiltasten oder {cmd}+Pfeiltasten ändern die Größe, Eingabe wählt aus, Esc bricht ab"
|
||||
"Send backward" = "Eine Ebene nach hinten"
|
||||
"Send report" = "Meldung senden"
|
||||
"Send to back" = "In den Hintergrund"
|
||||
"Server address" = "Serveradresse"
|
||||
"Server address (ws://host:47900/)" = "Serveradresse (ws://host:47900/)"
|
||||
"Server for online sharing" = "Server für das Online-Teilen"
|
||||
"Set as default" = "Als Standard festlegen"
|
||||
"Set variable" = "Variable festlegen"
|
||||
"Set variable mode" = "Variablenmodus festlegen"
|
||||
|
|
@ -1066,7 +1123,15 @@ status = "draft — needs native review"
|
|||
"Shape builder" = "Formerstellung"
|
||||
"Shape tools" = "Formwerkzeuge"
|
||||
"Share" = "Teilen"
|
||||
"Share this file" = "Diese Datei teilen"
|
||||
"Share: live session, export" = "Teilen: Live-Sitzung, Export"
|
||||
"Shared" = "Geteilt"
|
||||
"Shared: copy its code from the file's menu" = "Geteilt: Kopieren Sie den Code über das Menü der Datei"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Zum Teilen einer Datei wird die Desktop-App benötigt; hier können Sie einer beitreten."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Teilen im lokalen Netzwerk (Online-Teilen ist aus: In den Einstellungen ist kein Server festgelegt)."
|
||||
"Sharing on your network · {people}" = "Wird in Ihrem Netzwerk geteilt · {people}"
|
||||
"Sharing online · {people}" = "Wird online geteilt · {people}"
|
||||
"Sharing stopped: the file stays on the server, open only to you" = "Teilen beendet: Die Datei bleibt auf dem Server, nur für Sie zugänglich"
|
||||
"Show" = "Anzeigen"
|
||||
"Show as grid" = "Als Raster anzeigen"
|
||||
"Show as list" = "Als Liste anzeigen"
|
||||
|
|
@ -1113,6 +1178,7 @@ status = "draft — needs native review"
|
|||
"Space evenly" = "Gleichmäßiger Abstand"
|
||||
"Space+drag" = "Leertaste+Ziehen"
|
||||
"Spacing" = "Abstand"
|
||||
"Spam, stolen work, abuse…" = "Spam, gestohlene Arbeit, Missbrauch…"
|
||||
"Splay" = "Spreizung"
|
||||
"Spotlight me" = "Spotlight auf mich"
|
||||
"Spread" = "Ausbreitung"
|
||||
|
|
@ -1126,11 +1192,14 @@ status = "draft — needs native review"
|
|||
"Start from a blank canvas" = "Mit einer leeren Arbeitsfläche beginnen"
|
||||
"Start on path" = "Anfang auf dem Pfad"
|
||||
"Start point" = "Startpunkt"
|
||||
"Start sharing" = "Teilen starten"
|
||||
"Starting to share…" = "Teilen wird gestartet…"
|
||||
"Status" = "Status"
|
||||
"Sticky" = "Fixiert"
|
||||
"Stop" = "Beenden"
|
||||
"Stop following {name}" = "{name} nicht mehr folgen"
|
||||
"Stop session" = "Sitzung beenden"
|
||||
"Stop sharing" = "Teilen beenden"
|
||||
"Stop spotlighting" = "Spotlight beenden"
|
||||
"Stop {n} {hex}" = "Farbunterbrechung {n} {hex}"
|
||||
"Stretch" = "Strecken"
|
||||
|
|
@ -1169,8 +1238,10 @@ status = "draft — needs native review"
|
|||
"Take the values from the first selected layer" = "Werte der ersten ausgewählten Ebene übernehmen"
|
||||
"Taper" = "Verjüngung"
|
||||
"Taper both ends" = "Beide Enden verjüngen"
|
||||
"Tell the server's operator what is wrong with it" = "Teilen Sie dem Betreiber des Servers mit, was damit nicht stimmt"
|
||||
"Temperature" = "Temperatur"
|
||||
"Template" = "Vorlage"
|
||||
"Terms" = "Bedingungen"
|
||||
"Text" = "Text"
|
||||
"Text align" = "Textausrichtung"
|
||||
"Text content" = "Textinhalt"
|
||||
|
|
@ -1179,20 +1250,31 @@ status = "draft — needs native review"
|
|||
"Texts" = "Texte"
|
||||
"Texture" = "Textur"
|
||||
"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Die Arbeitsfläche zeigt Farben in sRGB an; exportierte PNG-, JPG- und WebP-Dateien werden als Display P3 gekennzeichnet."
|
||||
"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Die Datei wird vom Server gelöscht: Niemand kann sie von dort wieder öffnen oder ihr beitreten, und das lässt sich nicht rückgängig machen. Geöffnete Kopien bleiben bei denen, die sie haben."
|
||||
"The file now holds another library" = "Die Datei enthält jetzt eine andere Bibliothek"
|
||||
"The file now holds another library (\"{name}\")" = "Die Datei enthält jetzt eine andere Bibliothek („{name}“)"
|
||||
"The files you shared or published online from this computer. They stay on their server until you remove them." = "Die Dateien, die Sie von diesem Computer aus online geteilt oder veröffentlicht haben. Sie bleiben auf ihrem Server, bis Sie sie entfernen."
|
||||
"The link carries the session's secret: share it only with people you invite." = "Der Link enthält das Geheimnis der Sitzung: Teilen Sie ihn nur mit eingeladenen Personen."
|
||||
"The name in your online files and on the server. The document inside keeps its own name." = "Der Name in Ihren Online-Dateien und auf dem Server. Das Dokument darin behält seinen eigenen Namen."
|
||||
"The selection uses several text styles: click to apply one" = "Die Auswahl verwendet mehrere Textstile: Klicken Sie, um einen anzuwenden"
|
||||
"The server deletes this file if nobody opens it before {date}." = "Der Server löscht diese Datei, wenn sie bis {date} niemand öffnet."
|
||||
"The whole file" = "Die ganze Datei"
|
||||
"Theme" = "Design"
|
||||
"There's no destination for that action" = "Für diese Aktion gibt es kein Ziel"
|
||||
"Thickness" = "Stärke"
|
||||
"This branch can't be reviewed: {e}" = "Dieser Branch kann nicht geprüft werden: {e}"
|
||||
"This branch was merged or archived." = "Dieser Branch wurde zusammengeführt oder archiviert."
|
||||
"This computer only" = "Nur dieser Computer"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Dieser Computer merkt sich das Eigentümer-Token, sodass sich die Datei über die Liste „Online“ auf dem Startbildschirm verwalten lässt. Kopieren Sie es, um die Datei von woanders zu verwalten: Es ist ein Passwort für diese Datei und wird nicht noch einmal angezeigt."
|
||||
"This condition is edited as an expression" = "Diese Bedingung wird als Ausdruck bearbeitet"
|
||||
"This file" = "Diese Datei"
|
||||
"This file has unsaved changes" = "Diese Datei hat nicht gespeicherte Änderungen"
|
||||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Diese Datei liegt auf {server} und ist nur für Sie zugänglich. Teilen Sie sie, um andere mit einem neuen Code hereinzulassen."
|
||||
"This frame is new on the branch." = "Dieser Rahmen ist im Branch neu."
|
||||
"This frame isn't on the branch." = "Dieser Rahmen ist nicht im Branch."
|
||||
"This server is open to the public: no key needed." = "Dieser Server ist öffentlich zugänglich: kein Schlüssel nötig."
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Dieser Server braucht seinen Schlüssel: Fügen Sie der Adresse ?key=… hinzu (`figcraft-server key` gibt ihn auf dem Rechner des Servers aus)."
|
||||
"This server takes files only with its key: see Advanced." = "Dieser Server nimmt Dateien nur mit seinem Schlüssel an: siehe Erweitert."
|
||||
"Tidy up" = "Aufräumen"
|
||||
"Tile" = "Kachel"
|
||||
"Tile type" = "Kacheltyp"
|
||||
|
|
@ -1214,10 +1296,12 @@ status = "draft — needs native review"
|
|||
"True" = "Wahr"
|
||||
"Truncate text" = "Text kürzen"
|
||||
"Truncate with an ellipsis" = "Mit Auslassungspunkten kürzen"
|
||||
"Try again" = "Erneut versuchen"
|
||||
"Try another search or filter." = "Versuchen Sie eine andere Suche oder einen anderen Filter."
|
||||
"Two digits" = "Zweistellig"
|
||||
"Type" = "Typ"
|
||||
"Type settings" = "Schrifteinstellungen"
|
||||
"Type the join code you were given, or paste an invite link." = "Geben Sie den Beitrittscode ein, den Sie erhalten haben, oder fügen Sie einen Einladungslink ein."
|
||||
"Typography" = "Typografie"
|
||||
"Underline" = "Unterstrichen"
|
||||
"Undo takes back only your own changes; what others changed since stays." = "Rückgängig macht nur Ihre eigenen Änderungen rückgängig; was andere seitdem geändert haben, bleibt."
|
||||
|
|
@ -1289,11 +1373,13 @@ status = "draft — needs native review"
|
|||
"Wedge" = "Keil"
|
||||
"Weight" = "Stärke"
|
||||
"What changed since it was marked ready for dev" = "Was sich seit der Markierung als bereit für die Entwicklung geändert hat"
|
||||
"What is wrong with this file? The server's operator gets your report." = "Was stimmt mit dieser Datei nicht? Der Betreiber des Servers erhält Ihre Meldung."
|
||||
"What it's for" = "Wofür sie gedacht ist"
|
||||
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Der Ausgangsstand dieser Änderung ist unbekannt: Sie erfolgte, bevor FigCraft Kopien von Rahmen mit dem Status „Bereit für Entwicklung“ aufbewahrte. Erledigen Sie die Änderung (oder markieren Sie den Rahmen erneut als bereit), um ab jetzt zu vergleichen."
|
||||
"While hovering" = "Beim Überfahren"
|
||||
"While pressing" = "Beim Drücken"
|
||||
"White" = "Weiß"
|
||||
"Who runs it, its terms and how long it keeps files" = "Wer ihn betreibt, seine Bedingungen und wie lange er Dateien aufbewahrt"
|
||||
"Why it changed" = "Warum es sich geändert hat"
|
||||
"Widgets" = "Widgets"
|
||||
"Width" = "Breite"
|
||||
|
|
@ -1305,8 +1391,10 @@ status = "draft — needs native review"
|
|||
"Yellow" = "Gelb"
|
||||
"Yesterday" = "Gestern"
|
||||
"You" = "Sie"
|
||||
"You'll get the shared file as a new tab and edit it together." = "Sie erhalten die geteilte Datei als neuen Tab und bearbeiten sie gemeinsam."
|
||||
"You're spotlighting: everyone follows your view" = "Ihr Spotlight ist an: Alle folgen Ihrer Ansicht"
|
||||
"Your copy of the file stays open" = "Ihre Kopie der Datei bleibt geöffnet"
|
||||
"Your copy stays open; the file stays on the server" = "Ihre Kopie bleibt geöffnet; die Datei bleibt auf dem Server"
|
||||
"Your name" = "Ihr Name"
|
||||
"Your name in comments" = "Ihr Name in Kommentaren"
|
||||
"Your system asks for reduced motion: animations stay off." = "Ihr System verlangt reduzierte Bewegung: Animationen bleiben aus."
|
||||
|
|
@ -1357,14 +1445,20 @@ status = "draft — needs native review"
|
|||
"{name} (you)" = "{name} (Sie)"
|
||||
"{name} ({layers} layers)" = "{name} ({layers} Ebenen)"
|
||||
"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, Standard {default}. Doppelklicken Sie auf den Regler, um zum Wert des Stils zurückzukehren; Esc während des Ziehens bricht ab."
|
||||
"{name} cleared from the list" = "{name} aus der Liste entfernt"
|
||||
"{name} from {path}" = "{name} aus {path}"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} wurde auf diesem Computer vergessen; die Datei bleibt auf {server}"
|
||||
"{name} is on the server." = "{name} liegt auf dem Server."
|
||||
"{name} is spotlighting" = "{name} hat das Spotlight"
|
||||
"{name} was removed from the server" = "{name} wurde vom Server entfernt"
|
||||
"{name} · deleted on main" = "{name} · in der Hauptdatei gelöscht"
|
||||
"{name} · deleted on the branch" = "{name} · im Branch gelöscht"
|
||||
"{name} · not published" = "{name} · nicht veröffentlicht"
|
||||
"{name} · on a server" = "{name} · auf einem Server"
|
||||
"{name} · published version {v}" = "{name} · veröffentlichte Version {v}"
|
||||
"{name} · version {v} on the server" = "{name} · Version {v} auf dem Server"
|
||||
"{name}, in a live session" = "{name}, in einer Live-Sitzung"
|
||||
"{name}, online, open only to you" = "{name}, online, nur für Sie zugänglich"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, ausgewählt"
|
||||
"{n} added" = { one = "{n} hinzugefügt", other = "{n} hinzugefügt" }
|
||||
"{n} autosave versions" = { one = "{n} automatisch gespeicherte Version", other = "{n} automatisch gespeicherte Versionen" }
|
||||
|
|
@ -1402,6 +1496,9 @@ status = "draft — needs native review"
|
|||
"{page}, nothing selected" = "{page}, nichts ausgewählt"
|
||||
"{profile} (current)" = "{profile} (aktuell)"
|
||||
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} ist ihr Wert; {property ? \"a\" : \"b\"} wählt eine Zeichenfolge. Jedes andere {…} bleibt unverändert."
|
||||
"{server} can't say what became of your files (it is older than this app)." = "{server} kann nicht sagen, was aus Ihren Dateien geworden ist (er ist älter als diese App)."
|
||||
"{server} · No longer on the server" = "{server} · Nicht mehr auf dem Server"
|
||||
"{server} · {shared} · Opened {date}" = "{server} · {shared} · Geöffnet am {date}"
|
||||
"{tool} hold {key}" = "{tool} {key} gedrückt halten"
|
||||
"{value} isn't a colour" = "{value} ist keine Farbe"
|
||||
"{when} · edited" = "{when} · bearbeitet"
|
||||
|
|
@ -1460,13 +1557,20 @@ status = "draft — needs native review"
|
|||
"collab.cursor" = "Cursor anzeigen bei"
|
||||
"collab.cursorChat" = "Cursor-Chat"
|
||||
"collab.diff" = "Synchronisierte Änderungen anzeigen"
|
||||
"collab.files" = "Ihre Online-Dateien"
|
||||
"collab.follow" = "Folgen"
|
||||
"collab.forgetFile" = "Online-Datei vergessen"
|
||||
"collab.host" = "Live-Sitzung starten"
|
||||
"collab.join" = "Live-Sitzung beitreten"
|
||||
"collab.leave" = "Live-Sitzung verlassen"
|
||||
"collab.manageFile" = "Veröffentlichte Datei verwalten"
|
||||
"collab.openFile" = "Online-Datei öffnen"
|
||||
"collab.peers" = "Personen in dieser Datei"
|
||||
"collab.publish" = "Auf Server veröffentlichen"
|
||||
"collab.registerLinks" = "Einladungslinks in FigCraft öffnen"
|
||||
"collab.report" = "Datei dem Serverbetreiber melden"
|
||||
"collab.serverInfo" = "Serverinformationen"
|
||||
"collab.share" = "Mit Beitrittscode teilen"
|
||||
"collab.spotlight" = "Spotlight auf mich"
|
||||
"collab.stop" = "Live-Sitzung beenden"
|
||||
"command.batch" = "Mehrere Befehle ausführen"
|
||||
|
|
@ -1685,6 +1789,7 @@ status = "draft — needs native review"
|
|||
"pref.language" = "Sprache der Benutzeroberfläche"
|
||||
"pref.nudge" = "Schrittweite"
|
||||
"pref.numberKeysOpacity" = "Zifferntasten für Deckkraft verwenden"
|
||||
"pref.publishServer" = "Server für das Online-Teilen"
|
||||
"pref.reduceMotion" = "Bewegung reduzieren"
|
||||
"pref.renameDuplicates" = "Duplizierte Ebenen umbenennen"
|
||||
"pref.showDimensions" = "Abmessungen an Objekten anzeigen"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ name = "English"
|
|||
"About this server" = "About this server"
|
||||
"Action" = "Action"
|
||||
"Actions" = "Actions"
|
||||
"Actions for {name}" = "Actions for {name}"
|
||||
"Actions…" = "Actions…"
|
||||
"Active" = "Active"
|
||||
"Actual size (100%)" = "Actual size (100%)"
|
||||
|
|
@ -184,6 +185,8 @@ name = "English"
|
|||
"C or ⇧C" = "C or ⇧C"
|
||||
"CMYK colour" = "CMYK colour"
|
||||
"Can view" = "Can view"
|
||||
"Checking the server…" = "Checking the server…"
|
||||
"Clear from this list" = "Clear from this list"
|
||||
"Code or invite link" = "Code or invite link"
|
||||
"Color profile: {name}" = "Color profile: {name}"
|
||||
"CSS custom properties…" = "CSS custom properties…"
|
||||
|
|
@ -293,11 +296,11 @@ name = "English"
|
|||
"Copy as SVG" = "Copy as SVG"
|
||||
"Copy code" = "Copy code"
|
||||
"Copy link" = "Copy link"
|
||||
"Copy the owner token and keep it somewhere safe: it is the only way to revoke these links, rename the file or delete it from the server, and it won't be shown again." = "Copy the owner token and keep it somewhere safe: it is the only way to revoke these links, rename the file or delete it from the server, and it won't be shown again."
|
||||
"Copy the {label}" = "Copy the {label}"
|
||||
"Copy {value}" = "Copy {value}"
|
||||
"Corner radius" = "Corner radius"
|
||||
"Corner smoothing" = "Corner smoothing"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers."
|
||||
"Count" = "Count"
|
||||
"Create" = "Create"
|
||||
"Create a variable collection first" = "Create a variable collection first"
|
||||
|
|
@ -500,6 +503,7 @@ name = "English"
|
|||
"Font not installed" = "Font not installed"
|
||||
"Font size" = "Font size"
|
||||
"Font style" = "Font style"
|
||||
"Forget on this computer" = "Forget on this computer"
|
||||
"Fractions" = "Fractions"
|
||||
"Frame" = "Frame"
|
||||
"Frame selection" = "Frame selection"
|
||||
|
|
@ -809,6 +813,7 @@ name = "English"
|
|||
"Note {n}" = "Note {n}"
|
||||
"Nothing changed since this was marked ready for dev." = "Nothing changed since this was marked ready for dev."
|
||||
"Nothing here yet. Set a frame's status to list it." = "Nothing here yet. Set a frame's status to list it."
|
||||
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Nothing online yet. Use Share on a file to put it online and it is listed here."
|
||||
"Nothing to export" = "Nothing to export"
|
||||
"Now" = "Now"
|
||||
"Nudge (Shift: big nudge)" = "Nudge (Shift: big nudge)"
|
||||
|
|
@ -826,6 +831,7 @@ name = "English"
|
|||
"On drag" = "On drag"
|
||||
"On this page" = "On this page"
|
||||
"Online" = "Online"
|
||||
"Online files" = "Online files"
|
||||
"Online sharing is off: files are shared on the local network only." = "Online sharing is off: files are shared on the local network only."
|
||||
"Only the first changed layers are listed." = "Only the first changed layers are listed."
|
||||
"Only the first {n} changed layers are listed." = "Only the first {n} changed layers are listed."
|
||||
|
|
@ -976,6 +982,7 @@ name = "English"
|
|||
"Remove auto layout" = "Remove auto layout"
|
||||
"Remove dev resource" = "Remove dev resource"
|
||||
"Remove export setting" = "Remove export setting"
|
||||
"Remove from server" = "Remove from server"
|
||||
"Remove from server…" = "Remove from server…"
|
||||
"Remove guide" = "Remove guide"
|
||||
"Remove interaction" = "Remove interaction"
|
||||
|
|
@ -991,6 +998,7 @@ name = "English"
|
|||
"Remove the else and its actions" = "Remove the else and its actions"
|
||||
"Remove this condition and its actions" = "Remove this condition and its actions"
|
||||
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here."
|
||||
"Remove {name} from {server}?" = "Remove {name} from {server}?"
|
||||
"Removed" = "Removed"
|
||||
"Removed from the server" = "Removed from the server"
|
||||
"Removed since it was marked" = "Removed since it was marked"
|
||||
|
|
@ -1000,6 +1008,8 @@ name = "English"
|
|||
"Rename group" = "Rename group"
|
||||
"Rename layers" = "Rename layers"
|
||||
"Rename mode" = "Rename mode"
|
||||
"Rename on the server" = "Rename on the server"
|
||||
"Rename on the server…" = "Rename on the server…"
|
||||
"Rename page" = "Rename page"
|
||||
"Rename to" = "Rename to"
|
||||
"Replace all" = "Replace all"
|
||||
|
|
@ -1115,10 +1125,13 @@ name = "English"
|
|||
"Share" = "Share"
|
||||
"Share this file" = "Share this file"
|
||||
"Share: live session, export" = "Share: live session, export"
|
||||
"Shared" = "Shared"
|
||||
"Shared: copy its code from the file's menu" = "Shared: copy its code from the file's menu"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Sharing a file needs the desktop app; here you can join one."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Sharing on the local network (online sharing is off: no server is set in Preferences)."
|
||||
"Sharing on your network · {people}" = "Sharing on your network · {people}"
|
||||
"Sharing online · {people}" = "Sharing online · {people}"
|
||||
"Sharing stopped: the file stays on the server, open only to you" = "Sharing stopped: the file stays on the server, open only to you"
|
||||
"Show" = "Show"
|
||||
"Show as grid" = "Show as grid"
|
||||
"Show as list" = "Show as list"
|
||||
|
|
@ -1237,9 +1250,12 @@ name = "English"
|
|||
"Texts" = "Texts"
|
||||
"Texture" = "Texture"
|
||||
"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3."
|
||||
"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them."
|
||||
"The file now holds another library" = "The file now holds another library"
|
||||
"The file now holds another library (\"{name}\")" = "The file now holds another library (\"{name}\")"
|
||||
"The files you shared or published online from this computer. They stay on their server until you remove them." = "The files you shared or published online from this computer. They stay on their server until you remove them."
|
||||
"The link carries the session's secret: share it only with people you invite." = "The link carries the session's secret: share it only with people you invite."
|
||||
"The name in your online files and on the server. The document inside keeps its own name." = "The name in your online files and on the server. The document inside keeps its own name."
|
||||
"The selection uses several text styles: click to apply one" = "The selection uses several text styles: click to apply one"
|
||||
"The server deletes this file if nobody opens it before {date}." = "The server deletes this file if nobody opens it before {date}."
|
||||
"The whole file" = "The whole file"
|
||||
|
|
@ -1249,6 +1265,7 @@ name = "English"
|
|||
"This branch can't be reviewed: {e}" = "This branch can't be reviewed: {e}"
|
||||
"This branch was merged or archived." = "This branch was merged or archived."
|
||||
"This computer only" = "This computer only"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again."
|
||||
"This condition is edited as an expression" = "This condition is edited as an expression"
|
||||
"This file" = "This file"
|
||||
"This file has unsaved changes" = "This file has unsaved changes"
|
||||
|
|
@ -1279,6 +1296,7 @@ name = "English"
|
|||
"True" = "True"
|
||||
"Truncate text" = "Truncate text"
|
||||
"Truncate with an ellipsis" = "Truncate with an ellipsis"
|
||||
"Try again" = "Try again"
|
||||
"Try another search or filter." = "Try another search or filter."
|
||||
"Two digits" = "Two digits"
|
||||
"Type" = "Type"
|
||||
|
|
@ -1421,6 +1439,8 @@ name = "English"
|
|||
"{instances} in {files}" = "{instances} in {files}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}: draft translation, waiting for a native speaker's review."
|
||||
"{length} bleed" = "{length} bleed"
|
||||
"{name} cleared from the list" = "{name} cleared from the list"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} is forgotten on this computer; the file stays on {server}"
|
||||
"{name} is on the server." = "{name} is on the server."
|
||||
"{name} is spotlighting" = "{name} is spotlighting"
|
||||
"{name}\nClick to apply to the selection" = "{name}\nClick to apply to the selection"
|
||||
|
|
@ -1430,12 +1450,15 @@ name = "English"
|
|||
"{name} ({layers} layers)" = "{name} ({layers} layers)"
|
||||
"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels."
|
||||
"{name} from {path}" = "{name} from {path}"
|
||||
"{name} was removed from the server" = "{name} was removed from the server"
|
||||
"{name} · deleted on main" = "{name} · deleted on main"
|
||||
"{name} · deleted on the branch" = "{name} · deleted on the branch"
|
||||
"{name} · not published" = "{name} · not published"
|
||||
"{name} · on a server" = "{name} · on a server"
|
||||
"{name} · published version {v}" = "{name} · published version {v}"
|
||||
"{name} · version {v} on the server" = "{name} · version {v} on the server"
|
||||
"{name}, in a live session" = "{name}, in a live session"
|
||||
"{name}, online, open only to you" = "{name}, online, open only to you"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} by {h}, selected"
|
||||
"{n} added" = { one = "{n} added", other = "{n} added" }
|
||||
"{n} autosave versions" = { one = "{n} autosave version", other = "{n} autosave versions" }
|
||||
|
|
@ -1473,6 +1496,9 @@ name = "English"
|
|||
"{page}, nothing selected" = "{page}, nothing selected"
|
||||
"{profile} (current)" = "{profile} (current)"
|
||||
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written."
|
||||
"{server} can't say what became of your files (it is older than this app)." = "{server} can't say what became of your files (it is older than this app)."
|
||||
"{server} · No longer on the server" = "{server} · No longer on the server"
|
||||
"{server} · {shared} · Opened {date}" = "{server} · {shared} · Opened {date}"
|
||||
"{tool} hold {key}" = "{tool} hold {key}"
|
||||
"{value} isn't a colour" = "{value} isn't a colour"
|
||||
"{when} · edited" = "{when} · edited"
|
||||
|
|
|
|||
|
|
@ -50,12 +50,14 @@ status = "draft — needs native review"
|
|||
"Added the library \"{name}\"" = "Biblioteca \"{name}\" agregada"
|
||||
"Binary assets added" = "Recursos binarios agregados"
|
||||
"Controller" = "Control"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que esta computadora recuerda."
|
||||
"Drag the stroke to add a width point" = "Arrastra el trazo para agregar un punto de ancho"
|
||||
"Effect settings" = "Configuración del efecto"
|
||||
"Enter" = "Enter"
|
||||
"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Escribe la dirección de un figcraft-server y presiona Explorar para ver los plugins que ofrece."
|
||||
"Enter to save · Esc to cancel" = "Enter para guardar · Esc para cancelar"
|
||||
"Export settings" = "Configuración de exportación"
|
||||
"Forget on this computer" = "Olvidar en esta computadora"
|
||||
"Key/Gamepad" = "Tecla/Control"
|
||||
"Key/Gamepad {key}" = "Tecla/Control {key}"
|
||||
"Layout grid settings" = "Configuración de la cuadrícula de diseño"
|
||||
|
|
@ -71,8 +73,14 @@ status = "draft — needs native review"
|
|||
"Select a frame and add a starting point to present it as a flow." = "Selecciona un marco y agrega un punto de inicio para presentarlo como flujo."
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Cuadro de selección, {w} por {h} en {x}, {y}: las flechas lo mueven, Mayús+flechas o {cmd}+flechas cambian su tamaño, Enter selecciona, Esc cancela"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Cuadro de selección: las flechas lo mueven, Mayús+flechas o {cmd}+flechas cambian su tamaño, Enter selecciona, Esc cancela"
|
||||
"The files you shared or published online from this computer. They stay on their server until you remove them." = "Los archivos que compartiste o publicaste en línea desde esta computadora. Se quedan en su servidor hasta que los quites."
|
||||
"This computer only" = "Solo esta computadora"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Esta computadora recuerda el token de propietario, así que el archivo se puede gestionar desde la lista En línea de la pantalla de inicio. Cópialo para gestionar el archivo desde otro lugar: es una contraseña para este archivo y no se volverá a mostrar."
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Este servidor necesita su clave: agrega ?key=… a la dirección (`figcraft-server key` la muestra en el equipo del servidor)."
|
||||
"Type settings" = "Configuración de texto"
|
||||
"With the layers' export settings" = "Con la configuración de exportación de las capas"
|
||||
"You'll get the shared file as a new tab and edit it together." = "Recibirás el archivo compartido en una pestaña nueva y lo editarán en conjunto."
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} se olvidó en esta computadora; el archivo se queda en {server}"
|
||||
"{n} added" = { one = "{n} agregada", other = "{n} agregadas" }
|
||||
|
||||
[commands]
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ status = "draft — needs native review"
|
|||
"About this server" = "Acerca de este servidor"
|
||||
"Action" = "Acción"
|
||||
"Actions" = "Acciones"
|
||||
"Actions for {name}" = "Acciones para {name}"
|
||||
"Actions…" = "Acciones…"
|
||||
"Active" = "Activa"
|
||||
"Actual size (100%)" = "Tamaño real (100%)"
|
||||
|
|
@ -185,6 +186,8 @@ status = "draft — needs native review"
|
|||
"C or ⇧C" = "C o ⇧C"
|
||||
"CMYK colour" = "Color CMYK"
|
||||
"Can view" = "Puede ver"
|
||||
"Checking the server…" = "Comprobando el servidor…"
|
||||
"Clear from this list" = "Borrar de esta lista"
|
||||
"Code or invite link" = "Código o enlace de invitación"
|
||||
"Color profile: {name}" = "Perfil de color: {name}"
|
||||
"CSS custom properties…" = "Propiedades personalizadas CSS…"
|
||||
|
|
@ -294,11 +297,11 @@ status = "draft — needs native review"
|
|||
"Copy as SVG" = "Copiar como SVG"
|
||||
"Copy code" = "Copiar código"
|
||||
"Copy link" = "Copiar enlace"
|
||||
"Copy the owner token and keep it somewhere safe: it is the only way to revoke these links, rename the file or delete it from the server, and it won't be shown again." = "Copia el token de propietario y guárdalo en un lugar seguro: es la única forma de revocar estos enlaces, cambiar el nombre del archivo o eliminarlo del servidor, y no se volverá a mostrar."
|
||||
"Copy the {label}" = "Copiar {label}"
|
||||
"Copy {value}" = "Copiar {value}"
|
||||
"Corner radius" = "Radio de esquina"
|
||||
"Corner smoothing" = "Suavizado de esquinas"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que este ordenador recuerda."
|
||||
"Count" = "Número"
|
||||
"Create" = "Crear"
|
||||
"Create a variable collection first" = "Crea primero una colección de variables"
|
||||
|
|
@ -501,6 +504,7 @@ status = "draft — needs native review"
|
|||
"Font not installed" = "Fuente no instalada"
|
||||
"Font size" = "Tamaño de fuente"
|
||||
"Font style" = "Estilo de fuente"
|
||||
"Forget on this computer" = "Olvidar en este ordenador"
|
||||
"Fractions" = "Fracciones"
|
||||
"Frame" = "Marco"
|
||||
"Frame selection" = "Enmarcar selección"
|
||||
|
|
@ -810,6 +814,7 @@ status = "draft — needs native review"
|
|||
"Note {n}" = "Nota {n}"
|
||||
"Nothing changed since this was marked ready for dev." = "Nada cambió desde que se marcó como listo para desarrollo."
|
||||
"Nothing here yet. Set a frame's status to list it." = "Aún no hay nada. Asigna un estado a un marco para verlo aquí."
|
||||
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Aún no hay nada en línea. Usa Compartir en un archivo para ponerlo en línea y aparecerá aquí."
|
||||
"Nothing to export" = "Nada que exportar"
|
||||
"Now" = "Ahora"
|
||||
"Nudge (Shift: big nudge)" = "Desplazar (Mayús: desplazamiento grande)"
|
||||
|
|
@ -827,6 +832,7 @@ status = "draft — needs native review"
|
|||
"On drag" = "Al arrastrar"
|
||||
"On this page" = "En esta página"
|
||||
"Online" = "En línea"
|
||||
"Online files" = "Archivos en línea"
|
||||
"Online sharing is off: files are shared on the local network only." = "Compartir en línea está desactivado: los archivos solo se comparten en la red local."
|
||||
"Only the first changed layers are listed." = "Solo se muestran las primeras capas cambiadas."
|
||||
"Only the first {n} changed layers are listed." = "Solo se muestran las primeras {n} capas cambiadas."
|
||||
|
|
@ -977,6 +983,7 @@ status = "draft — needs native review"
|
|||
"Remove auto layout" = "Quitar diseño automático"
|
||||
"Remove dev resource" = "Quitar recurso de desarrollo"
|
||||
"Remove export setting" = "Quitar ajuste de exportación"
|
||||
"Remove from server" = "Quitar del servidor"
|
||||
"Remove from server…" = "Quitar del servidor…"
|
||||
"Remove guide" = "Quitar guía"
|
||||
"Remove interaction" = "Quitar interacción"
|
||||
|
|
@ -992,6 +999,7 @@ status = "draft — needs native review"
|
|||
"Remove the else and its actions" = "Quitar el «si no» y sus acciones"
|
||||
"Remove this condition and its actions" = "Quitar esta condición y sus acciones"
|
||||
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "¿Quitar este archivo del servidor? No se podrá volver a compartir ni abrir desde allí. Tu copia sigue abierta aquí."
|
||||
"Remove {name} from {server}?" = "¿Quitar {name} de {server}?"
|
||||
"Removed" = "Eliminado"
|
||||
"Removed from the server" = "Quitado del servidor"
|
||||
"Removed since it was marked" = "Eliminada desde que se marcó"
|
||||
|
|
@ -1001,6 +1009,8 @@ status = "draft — needs native review"
|
|||
"Rename group" = "Cambiar nombre del grupo"
|
||||
"Rename layers" = "Cambiar nombre de capas"
|
||||
"Rename mode" = "Cambiar nombre del modo"
|
||||
"Rename on the server" = "Cambiar el nombre en el servidor"
|
||||
"Rename on the server…" = "Cambiar el nombre en el servidor…"
|
||||
"Rename page" = "Cambiar nombre de página"
|
||||
"Rename to" = "Nuevo nombre"
|
||||
"Replace all" = "Reemplazar todo"
|
||||
|
|
@ -1116,10 +1126,13 @@ status = "draft — needs native review"
|
|||
"Share" = "Compartir"
|
||||
"Share this file" = "Compartir este archivo"
|
||||
"Share: live session, export" = "Compartir: sesión en vivo, exportar"
|
||||
"Shared" = "Compartido"
|
||||
"Shared: copy its code from the file's menu" = "Compartido: copia su código desde el menú del archivo"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Compartir un archivo requiere la aplicación de escritorio; aquí puedes unirte a uno."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Se comparte en la red local (compartir en línea está desactivado: no hay servidor en Preferencias)."
|
||||
"Sharing on your network · {people}" = "Compartiendo en tu red · {people}"
|
||||
"Sharing online · {people}" = "Compartiendo en línea · {people}"
|
||||
"Sharing stopped: the file stays on the server, open only to you" = "Se dejó de compartir: el archivo se queda en el servidor, abierto solo para ti"
|
||||
"Show" = "Mostrar"
|
||||
"Show as grid" = "Mostrar como cuadrícula"
|
||||
"Show as list" = "Mostrar como lista"
|
||||
|
|
@ -1238,9 +1251,12 @@ status = "draft — needs native review"
|
|||
"Texts" = "Textos"
|
||||
"Texture" = "Textura"
|
||||
"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "El lienzo muestra los colores en sRGB; los archivos PNG, JPG y WebP exportados se etiquetan como Display P3."
|
||||
"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "El archivo se elimina del servidor: nadie podrá volver a abrirlo ni unirse a él desde allí, y no se puede deshacer. Las copias abiertas se quedan con quien las tenga."
|
||||
"The file now holds another library" = "El archivo ahora contiene otra biblioteca"
|
||||
"The file now holds another library (\"{name}\")" = "El archivo ahora contiene otra biblioteca (\"{name}\")"
|
||||
"The files you shared or published online from this computer. They stay on their server until you remove them." = "Los archivos que compartiste o publicaste en línea desde este ordenador. Se quedan en su servidor hasta que los quites."
|
||||
"The link carries the session's secret: share it only with people you invite." = "El enlace lleva la clave secreta de la sesión: compártelo solo con quienes invites."
|
||||
"The name in your online files and on the server. The document inside keeps its own name." = "El nombre en tus archivos en línea y en el servidor. El documento que contiene conserva su propio nombre."
|
||||
"The selection uses several text styles: click to apply one" = "La selección usa varios estilos de texto: haz clic para aplicar uno"
|
||||
"The server deletes this file if nobody opens it before {date}." = "El servidor elimina este archivo si nadie lo abre antes del {date}."
|
||||
"The whole file" = "El archivo completo"
|
||||
|
|
@ -1250,6 +1266,7 @@ status = "draft — needs native review"
|
|||
"This branch can't be reviewed: {e}" = "Esta rama no se puede revisar: {e}"
|
||||
"This branch was merged or archived." = "Esta rama se fusionó o archivó."
|
||||
"This computer only" = "Solo este ordenador"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Este ordenador recuerda el token de propietario, así que el archivo se puede gestionar desde la lista En línea de la pantalla de inicio. Cópialo para gestionar el archivo desde otro lugar: es una contraseña para este archivo y no se volverá a mostrar."
|
||||
"This condition is edited as an expression" = "Esta condición se edita como expresión"
|
||||
"This file" = "Este archivo"
|
||||
"This file has unsaved changes" = "Este archivo tiene cambios sin guardar"
|
||||
|
|
@ -1280,6 +1297,7 @@ status = "draft — needs native review"
|
|||
"True" = "Verdadero"
|
||||
"Truncate text" = "Truncar texto"
|
||||
"Truncate with an ellipsis" = "Truncar con puntos suspensivos"
|
||||
"Try again" = "Reintentar"
|
||||
"Try another search or filter." = "Prueba otra búsqueda u otro filtro."
|
||||
"Two digits" = "Dos dígitos"
|
||||
"Type" = "Tipo"
|
||||
|
|
@ -1422,6 +1440,8 @@ status = "draft — needs native review"
|
|||
"{instances} in {files}" = "{instances} en {files}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}: traducción en borrador, pendiente de revisión por un hablante nativo."
|
||||
"{length} bleed" = "sangrado de {length}"
|
||||
"{name} cleared from the list" = "{name} se borró de la lista"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} se olvidó en este ordenador; el archivo se queda en {server}"
|
||||
"{name} is on the server." = "{name} está en el servidor."
|
||||
"{name} is spotlighting" = "{name} está en el foco"
|
||||
"{name}\nClick to apply to the selection" = "{name}\nHaz clic para aplicarlo a la selección"
|
||||
|
|
@ -1431,12 +1451,15 @@ status = "draft — needs native review"
|
|||
"{name} ({layers} layers)" = "{name} ({layers} capas)"
|
||||
"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, predeterminado {default}. Haz doble clic en el control para volver al valor del estilo; Escape mientras arrastras cancela."
|
||||
"{name} from {path}" = "{name} de {path}"
|
||||
"{name} was removed from the server" = "{name} se quitó del servidor"
|
||||
"{name} · deleted on main" = "{name} · eliminado en la principal"
|
||||
"{name} · deleted on the branch" = "{name} · eliminado en la rama"
|
||||
"{name} · not published" = "{name} · sin publicar"
|
||||
"{name} · on a server" = "{name} · en un servidor"
|
||||
"{name} · published version {v}" = "{name} · versión publicada {v}"
|
||||
"{name} · version {v} on the server" = "{name} · versión {v} en el servidor"
|
||||
"{name}, in a live session" = "{name}, en una sesión en vivo"
|
||||
"{name}, online, open only to you" = "{name}, en línea, abierto solo para ti"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} por {h}, seleccionado"
|
||||
"{n} added" = { one = "{n} añadida", other = "{n} añadidas" }
|
||||
"{n} autosave versions" = { one = "{n} versión autoguardada", other = "{n} versiones autoguardadas" }
|
||||
|
|
@ -1474,6 +1497,9 @@ status = "draft — needs native review"
|
|||
"{page}, nothing selected" = "{page}, nada seleccionado"
|
||||
"{profile} (current)" = "{profile} (actual)"
|
||||
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} es su valor; {property ? \"a\" : \"b\"} elige un texto. Cualquier otro {…} se queda tal cual."
|
||||
"{server} can't say what became of your files (it is older than this app)." = "{server} no puede decir qué fue de tus archivos (es anterior a esta aplicación)."
|
||||
"{server} · No longer on the server" = "{server} · Ya no está en el servidor"
|
||||
"{server} · {shared} · Opened {date}" = "{server} · {shared} · Abierto el {date}"
|
||||
"{tool} hold {key}" = "{tool} mantén {key}"
|
||||
"{value} isn't a colour" = "{value} no es un color"
|
||||
"{when} · edited" = "{when} · editado"
|
||||
|
|
@ -1532,11 +1558,14 @@ status = "draft — needs native review"
|
|||
"collab.cursor" = "Mostrar cursor en"
|
||||
"collab.cursorChat" = "Chat del cursor"
|
||||
"collab.diff" = "Mostrar cambios sincronizados"
|
||||
"collab.files" = "Tus archivos en línea"
|
||||
"collab.follow" = "Seguir"
|
||||
"collab.forgetFile" = "Olvidar archivo en línea"
|
||||
"collab.host" = "Iniciar sesión en vivo"
|
||||
"collab.join" = "Unirse a una sesión en vivo"
|
||||
"collab.leave" = "Salir de la sesión en vivo"
|
||||
"collab.manageFile" = "Gestionar archivo publicado"
|
||||
"collab.openFile" = "Abrir archivo en línea"
|
||||
"collab.peers" = "Personas en este archivo"
|
||||
"collab.publish" = "Publicar en el servidor"
|
||||
"collab.registerLinks" = "Abrir enlaces de invitación en FigCraft"
|
||||
|
|
|
|||
|
|
@ -13,14 +13,17 @@ status = "draft — needs native review"
|
|||
"(no longer published)" = "(plus publié)"
|
||||
"({n} warnings: some entries were skipped)" = { one = "({n} avertissement : certaines entrées ont été ignorées)", other = "({n} avertissements : certaines entrées ont été ignorées)" }
|
||||
"+ Create variable" = "+ Créer une variable"
|
||||
".fig (experimental)" = ".fig (expérimental)"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login} : {prs} PR, {commits} commits, +{added} / −{deleted} lignes (Δ {delta}), +{bin_added} / −{bin_deleted} ressources binaires, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Une collection de ce fichier avec ces modes et ces variables : remplacez leurs valeurs par mode et ajoutez vos propres variables"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Un ressort joue jusqu’à ce qu’il se stabilise : sa masse, sa rigidité et son amortissement en fixent la durée"
|
||||
"About" = "À propos"
|
||||
"About FigCraft" = "À propos de FigCraft"
|
||||
"About this server" = "À propos de ce serveur"
|
||||
"Action" = "Action"
|
||||
"Actions" = "Actions"
|
||||
"Actions for {name}" = "Actions pour {name}"
|
||||
"Actions…" = "Actions…"
|
||||
"Active" = "Active"
|
||||
"Actual size (100%)" = "Taille réelle (100 %)"
|
||||
|
|
@ -62,6 +65,7 @@ status = "draft — needs native review"
|
|||
"Added the library \"{name}\"" = "Bibliothèque « {name} » ajoutée"
|
||||
"Adjust" = "Réglages"
|
||||
"Adjustment presets" = "Préréglages"
|
||||
"Advanced" = "Avancé"
|
||||
"Advanced stroke" = "Contour avancé"
|
||||
"After delay" = "Après un délai"
|
||||
"After delay {ms}ms" = "Après {ms} ms"
|
||||
|
|
@ -85,14 +89,19 @@ status = "draft — needs native review"
|
|||
"All variables" = "Toutes les variables"
|
||||
"Alpha" = "Alpha"
|
||||
"Alt+drag" = "Alt+glisser"
|
||||
"An invite link that opens FigCraft and joins" = "Un lien d’invitation qui ouvre FigCraft et rejoint la session"
|
||||
"Anchor" = "Ancre"
|
||||
"Android" = "Android"
|
||||
"Angular" = "Angulaire"
|
||||
"Animation" = "Animation"
|
||||
"Annotations" = "Annotations"
|
||||
"Annotations shown on the canvas" = "Annotations affichées sur le canevas"
|
||||
"Another server, or one that needs its key. The usual server is set in Preferences." = "Un autre serveur, ou un serveur qui exige sa clé. Le serveur habituel se choisit dans les Préférences."
|
||||
"Any status" = "Tous les statuts"
|
||||
"Anyone on your network with this code can edit the file, while this app stays open." = "Toute personne de votre réseau disposant de ce code peut modifier le fichier tant que cette application reste ouverte."
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Toute personne disposant du lien de modification peut modifier le fichier ; le lien de consultation permet seulement de le regarder. Les liens contiennent leurs secrets : partagez-les comme des mots de passe. `figcraft-server links` en crée d’autres et les révoque."
|
||||
"Anyone with this code can edit the file." = "Toute personne disposant de ce code peut modifier le fichier."
|
||||
"Anyone with this code can look at the file, not change it." = "Toute personne disposant de ce code peut consulter le fichier, pas le modifier."
|
||||
"Appearance" = "Apparence"
|
||||
"Apply a component property" = "Appliquer une propriété de composant"
|
||||
"Apply variable" = "Appliquer une variable"
|
||||
|
|
@ -107,6 +116,7 @@ status = "draft — needs native review"
|
|||
"Arrange" = "Disposer"
|
||||
"Arrow" = "Flèche"
|
||||
"As typed" = "Tel que saisi"
|
||||
"Asking the server…" = "Interrogation du serveur…"
|
||||
"Assets" = "Ressources"
|
||||
"Auto" = "Auto"
|
||||
"Auto (space around)" = "Auto (espace autour)"
|
||||
|
|
@ -119,6 +129,7 @@ status = "draft — needs native review"
|
|||
"Auto layout" = "Mise en page auto"
|
||||
"Auto rows" = "Lignes auto"
|
||||
"Auto width" = "Largeur auto"
|
||||
"Back to the FigCraft server" = "Revenir au serveur FigCraft"
|
||||
"Back to the font's value" = "Revenir à la valeur de la police"
|
||||
"Back" = "Retour"
|
||||
"Back to Ready for dev" = "Revenir à Prêt pour le dev"
|
||||
|
|
@ -174,6 +185,10 @@ status = "draft — needs native review"
|
|||
"C or Shift+C" = "C ou Maj+C"
|
||||
"C or ⇧C" = "C ou ⇧C"
|
||||
"CMYK colour" = "Couleur CMJN"
|
||||
"Can view" = "Peut consulter"
|
||||
"Checking the server…" = "Vérification auprès du serveur…"
|
||||
"Clear from this list" = "Effacer de cette liste"
|
||||
"Code or invite link" = "Code ou lien d’invitation"
|
||||
"Color profile: {name}" = "Profil colorimétrique : {name}"
|
||||
"CSS custom properties…" = "Propriétés personnalisées CSS…"
|
||||
"Can edit" = "Peut modifier"
|
||||
|
|
@ -280,11 +295,13 @@ status = "draft — needs native review"
|
|||
"Copy as CSS" = "Copier en CSS"
|
||||
"Copy as PNG" = "Copier en PNG"
|
||||
"Copy as SVG" = "Copier en SVG"
|
||||
"Copy code" = "Copier le code"
|
||||
"Copy link" = "Copier le lien"
|
||||
"Copy the {label}" = "Copier {label}"
|
||||
"Copy {value}" = "Copier {value}"
|
||||
"Corner radius" = "Rayon d’angle"
|
||||
"Corner smoothing" = "Lissage des angles"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient."
|
||||
"Count" = "Nombre"
|
||||
"Create" = "Créer"
|
||||
"Create a variable collection first" = "Créez d’abord une collection de variables"
|
||||
|
|
@ -401,6 +418,7 @@ status = "draft — needs native review"
|
|||
"Empty" = "Vide"
|
||||
"End" = "Fin"
|
||||
"End point" = "Point final"
|
||||
"Ends the session; everyone keeps their copy" = "Met fin à la session ; chacun conserve sa copie"
|
||||
"Enter" = "Entrée"
|
||||
"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Saisissez l’adresse d’un figcraft-server et appuyez sur Parcourir pour voir les plugins qu’il propose."
|
||||
"Enter to save · Esc to cancel" = "Entrée pour enregistrer · Échap pour annuler"
|
||||
|
|
@ -426,6 +444,7 @@ status = "draft — needs native review"
|
|||
"Export {n} layers" = { one = "Exporter {n} calque", other = "Exporter {n} calques" }
|
||||
"Exported {file}" = "Exporté : {file}"
|
||||
"Exported {name}" = "{name} exporté"
|
||||
"Exported {name}; {n} kinds of content were left out" = { one = "{name} exporté ; {n} type de contenu a été omis", other = "{name} exporté ; {n} types de contenu ont été omis" }
|
||||
"Exported {n} files" = { one = "{n} fichier exporté", other = "{n} fichiers exportés" }
|
||||
"Export…" = "Exporter…"
|
||||
"Expose properties to instances" = "Exposer les propriétés aux instances"
|
||||
|
|
@ -440,6 +459,9 @@ status = "draft — needs native review"
|
|||
"Fetching from the server…" = "Récupération depuis le serveur…"
|
||||
"File" = "Fichier"
|
||||
"File name" = "Nom du fichier"
|
||||
"File published" = "Fichier publié"
|
||||
"Files aren't end-to-end encrypted: the server's operator can read them." = "Les fichiers ne sont pas chiffrés de bout en bout : l’opérateur du serveur peut les lire."
|
||||
"Files nobody opens for {n} days are deleted." = { one = "Les fichiers que personne n’ouvre pendant {n} jour sont supprimés.", other = "Les fichiers que personne n’ouvre pendant {n} jours sont supprimés." }
|
||||
"Fill" = "Remplissage"
|
||||
"Fill (fr)" = "Remplir (fr)"
|
||||
"Fill container" = "Remplir le conteneur"
|
||||
|
|
@ -482,6 +504,7 @@ status = "draft — needs native review"
|
|||
"Font not installed" = "Police non installée"
|
||||
"Font size" = "Taille de police"
|
||||
"Font style" = "Style de police"
|
||||
"Forget on this computer" = "Oublier sur cet ordinateur"
|
||||
"Fractions" = "Fractions"
|
||||
"Frame" = "Cadre"
|
||||
"Frame selection" = "Encadrer la sélection"
|
||||
|
|
@ -495,6 +518,7 @@ status = "draft — needs native review"
|
|||
"Gap between columns" = "Espacement entre les colonnes"
|
||||
"Gap between items" = "Espacement entre les éléments"
|
||||
"Gap between rows" = "Espacement entre les lignes"
|
||||
"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Indiquez l’adresse d’un serveur ouvert au public, ou l’adresse d’un serveur avec sa clé (`figcraft-server key` l’affiche)."
|
||||
"Glass" = "Verre"
|
||||
"Go to main component" = "Aller au composant principal"
|
||||
"Go to {name}" = "Aller à {name}"
|
||||
|
|
@ -518,6 +542,7 @@ status = "draft — needs native review"
|
|||
"Help" = "Aide"
|
||||
"Help and resources" = "Aide et ressources"
|
||||
"Help page" = "Page d’aide"
|
||||
"Here: {names}" = "Présents : {names}"
|
||||
"Hidden" = "Masqué"
|
||||
"Hide contents" = "Masquer le contenu"
|
||||
"Hide from publishing" = "Exclure de la publication"
|
||||
|
|
@ -537,6 +562,7 @@ status = "draft — needs native review"
|
|||
"Horizontal hex" = "Hexagone horizontal"
|
||||
"Horizontal padding" = "Marge intérieure horizontale"
|
||||
"Horizontal spacing" = "Espacement horizontal"
|
||||
"Hosted by {operator}" = "Hébergé par {operator}"
|
||||
"Hover a layer to see its properties" = "Survolez un calque pour voir ses propriétés"
|
||||
"Hover other layers to measure the distance" = "Survolez d’autres calques pour mesurer la distance"
|
||||
"Hue" = "Teinte"
|
||||
|
|
@ -587,8 +613,11 @@ status = "draft — needs native review"
|
|||
"Invite link copied" = "Lien d’invitation copié"
|
||||
"It asks permission to:" = "Il demande l’autorisation de :"
|
||||
"Join" = "Rejoindre"
|
||||
"Join code copied" = "Code d’accès copié"
|
||||
"Join code or invite link" = "Code d’accès ou lien d’invitation"
|
||||
"Join live session" = "Rejoindre la session en direct"
|
||||
"Join us on Discord" = "Rejoignez-nous sur Discord"
|
||||
"Join with a code" = "Rejoindre avec un code"
|
||||
"Joined" = "Connecté"
|
||||
"Joined {file}" = "Connecté à {file}"
|
||||
"Joining…" = "Connexion…"
|
||||
|
|
@ -651,6 +680,7 @@ status = "draft — needs native review"
|
|||
"Live session" = "Session en direct"
|
||||
"Live · {people}" = "En direct · {people}"
|
||||
"Local components" = "Composants locaux"
|
||||
"Local network" = "Réseau local"
|
||||
"Local styles" = "Styles locaux"
|
||||
"Local variables" = "Variables locales"
|
||||
"Lock/Unlock" = "Verrouiller/déverrouiller"
|
||||
|
|
@ -740,6 +770,7 @@ status = "draft — needs native review"
|
|||
"No conflicts." = "Aucun conflit."
|
||||
"No contributor data was built into this copy." = "Cette copie ne contient aucune donnée de contributeurs."
|
||||
"No fonts match" = "Aucune police ne correspond"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Pas de code d’accès ici (le serveur est antérieur aux codes d’accès, ou cette adresse ne tient pas dans un code) : partagez plutôt le lien."
|
||||
"No layer changes to show." = "Aucune modification de calque à afficher."
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Aucune bibliothèque pour l’instant. Ajoutez un fichier .figcraft publié en tant que bibliothèque, ou collez le lien d’une bibliothèque d’équipe."
|
||||
"No list" = "Aucune liste"
|
||||
|
|
@ -778,9 +809,11 @@ status = "draft — needs native review"
|
|||
"Not in this font" = "Absent de cette police"
|
||||
"Not properties, kept as written: {names}" = "Pas des propriétés, conservés tels quels : {names}"
|
||||
"Not read yet" = "Pas encore lu"
|
||||
"Not shared" = "Non partagé"
|
||||
"Note {n}" = "Note {n}"
|
||||
"Nothing changed since this was marked ready for dev." = "Rien n’a changé depuis que ceci a été marqué prêt pour le dev."
|
||||
"Nothing here yet. Set a frame's status to list it." = "Rien pour l’instant. Attribuez un statut à un cadre pour le lister ici."
|
||||
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Rien en ligne pour l’instant. Utilisez Partager sur un fichier pour le mettre en ligne et il apparaîtra ici."
|
||||
"Nothing to export" = "Rien à exporter"
|
||||
"Now" = "Maintenant"
|
||||
"Nudge (Shift: big nudge)" = "Décaler (Maj : grand décalage)"
|
||||
|
|
@ -797,6 +830,9 @@ status = "draft — needs native review"
|
|||
"On click" = "Au clic"
|
||||
"On drag" = "Au glissement"
|
||||
"On this page" = "Sur cette page"
|
||||
"Online" = "En ligne"
|
||||
"Online files" = "Fichiers en ligne"
|
||||
"Online sharing is off: files are shared on the local network only." = "Le partage en ligne est désactivé : les fichiers ne sont partagés que sur le réseau local."
|
||||
"Only the first changed layers are listed." = "Seuls les premiers calques modifiés sont listés."
|
||||
"Only the first {n} changed layers are listed." = "Seuls les {n} premiers calques modifiés sont listés."
|
||||
"Only your threads" = "Vos fils uniquement"
|
||||
|
|
@ -820,6 +856,9 @@ status = "draft — needs native review"
|
|||
"Open|comments" = "Ouverts"
|
||||
"Options" = "Options"
|
||||
"Orange" = "Orange"
|
||||
"Other apps may not open every .fig file exported here." = "D’autres applications risquent de ne pas ouvrir tous les fichiers .fig exportés ici."
|
||||
"Others lose access and keep their copy; the file stays on the server for you" = "Les autres perdent l’accès et conservent leur copie ; le fichier reste sur le serveur pour vous"
|
||||
"Others type this under Share ▸ Join with a code" = "Les autres le saisissent dans Partager ▸ Rejoindre avec un code"
|
||||
"Outline stroke" = "Convertir le contour en tracé"
|
||||
"Outline text" = "Convertir le texte en tracés"
|
||||
"Outlines" = "Contours"
|
||||
|
|
@ -829,6 +868,8 @@ status = "draft — needs native review"
|
|||
"Overlay position" = "Position de la superposition"
|
||||
"Overridden here (the collection it extends has another value)" = "Remplacé ici (la collection étendue a une autre valeur)"
|
||||
"Overrides" = "Remplacements"
|
||||
"Owner token" = "Jeton de propriétaire"
|
||||
"Owner token copied" = "Jeton de propriétaire copié"
|
||||
"PDF print options" = "Options d’impression PDF"
|
||||
"PDF print settings" = "Réglages d’impression PDF"
|
||||
"Padding" = "Marge intérieure"
|
||||
|
|
@ -855,6 +896,8 @@ status = "draft — needs native review"
|
|||
"Pen" = "Plume"
|
||||
"Pencil" = "Crayon"
|
||||
"People in this file" = "Personnes dans ce fichier"
|
||||
"People join from anywhere with a code. The file is kept on {server} while you share it." = "On vous rejoint de partout avec un code. Le fichier est conservé sur {server} tant que vous le partagez."
|
||||
"People on your network join with a code. Nothing goes through a server." = "Les personnes de votre réseau vous rejoignent avec un code. Rien ne passe par un serveur."
|
||||
"Phone" = "Téléphone"
|
||||
"Pick color" = "Prélever une couleur"
|
||||
"Pick color (I)" = "Prélever une couleur (I)"
|
||||
|
|
@ -875,6 +918,7 @@ status = "draft — needs native review"
|
|||
"Points" = "Points"
|
||||
"Polygon" = "Polygone"
|
||||
"Polygons" = "Polygones"
|
||||
"Port" = "Port"
|
||||
"Position" = "Position"
|
||||
"Post" = "Publier"
|
||||
"Preferences" = "Préférences"
|
||||
|
|
@ -903,12 +947,14 @@ status = "draft — needs native review"
|
|||
"Prototype settings" = "Réglages du prototype"
|
||||
"Publish" = "Publier"
|
||||
"Publish changes" = "Publier les modifications"
|
||||
"Publish to another server…" = "Publier sur un autre serveur…"
|
||||
"Publish to server" = "Publier sur un serveur"
|
||||
"Published version {v}" = "Version {v} publiée"
|
||||
"Published version {v} to the server" = "Version {v} publiée sur le serveur"
|
||||
"Published version {v}: save the file so other files can use it" = "Version {v} publiée : enregistrez le fichier pour que d’autres fichiers puissent l’utiliser"
|
||||
"Published · {people}" = "Publié · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publier sur un serveur nécessite l’application de bureau ; dans le navigateur, vous pouvez rejoindre les fichiers d’un serveur"
|
||||
"Publishing with the server's key." = "Publication avec la clé du serveur."
|
||||
"Publishing…" = "Publication…"
|
||||
"Push" = "Pousser"
|
||||
"Push changes to main component" = "Transmettre les modifications au composant principal"
|
||||
|
|
@ -936,6 +982,8 @@ status = "draft — needs native review"
|
|||
"Remove auto layout" = "Retirer la mise en page auto"
|
||||
"Remove dev resource" = "Retirer la ressource de développement"
|
||||
"Remove export setting" = "Retirer le réglage d’exportation"
|
||||
"Remove from server" = "Retirer du serveur"
|
||||
"Remove from server…" = "Retirer du serveur…"
|
||||
"Remove guide" = "Retirer le repère"
|
||||
"Remove interaction" = "Retirer l’interaction"
|
||||
"Remove library from this file" = "Retirer la bibliothèque de ce fichier"
|
||||
|
|
@ -949,7 +997,10 @@ status = "draft — needs native review"
|
|||
"Remove text from path" = "Retirer le texte du tracé"
|
||||
"Remove the else and its actions" = "Retirer le « sinon » et ses actions"
|
||||
"Remove this condition and its actions" = "Retirer cette condition et ses actions"
|
||||
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Retirer ce fichier du serveur ? Il ne pourra plus être partagé ni ouvert depuis celui-ci. Votre copie reste ouverte ici."
|
||||
"Remove {name} from {server}?" = "Retirer {name} de {server} ?"
|
||||
"Removed" = "Supprimé"
|
||||
"Removed from the server" = "Retiré du serveur"
|
||||
"Removed since it was marked" = "Supprimé depuis le marquage"
|
||||
"Rename" = "Renommer"
|
||||
"Rename duplicated layers" = "Renommer les calques dupliqués"
|
||||
|
|
@ -957,6 +1008,8 @@ status = "draft — needs native review"
|
|||
"Rename group" = "Renommer le groupe"
|
||||
"Rename layers" = "Renommer les calques"
|
||||
"Rename mode" = "Renommer le mode"
|
||||
"Rename on the server" = "Renommer sur le serveur"
|
||||
"Rename on the server…" = "Renommer sur le serveur…"
|
||||
"Rename page" = "Renommer la page"
|
||||
"Rename to" = "Renommer en"
|
||||
"Replace all" = "Tout remplacer"
|
||||
|
|
@ -964,6 +1017,8 @@ status = "draft — needs native review"
|
|||
"Replace with" = "Remplacer par"
|
||||
"Replace with a new interaction" = "Remplacer par une nouvelle interaction"
|
||||
"Reply" = "Répondre"
|
||||
"Report" = "Signalement"
|
||||
"Report this file…" = "Signaler ce fichier…"
|
||||
"Reset" = "Réinitialiser"
|
||||
"Reset adjustments" = "Réinitialiser les réglages"
|
||||
"Reset all changes" = "Réinitialiser toutes les modifications"
|
||||
|
|
@ -1053,9 +1108,11 @@ status = "draft — needs native review"
|
|||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Zone de sélection, {w} sur {h} à {x}, {y} : les flèches la déplacent, Maj+flèches ou {cmd}+flèches la redimensionnent, Entrée sélectionne, Échap annule"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Zone de sélection : les flèches la déplacent, Maj+flèches ou {cmd}+flèches la redimensionnent, Entrée sélectionne, Échap annule"
|
||||
"Send backward" = "Reculer"
|
||||
"Send report" = "Envoyer le signalement"
|
||||
"Send to back" = "Mettre à l’arrière-plan"
|
||||
"Server address" = "Adresse du serveur"
|
||||
"Server address (ws://host:47900/)" = "Adresse du serveur (ws://host:47900/)"
|
||||
"Server for online sharing" = "Serveur pour le partage en ligne"
|
||||
"Set as default" = "Définir par défaut"
|
||||
"Set variable" = "Définir la variable"
|
||||
"Set variable mode" = "Définir le mode de variable"
|
||||
|
|
@ -1066,7 +1123,15 @@ status = "draft — needs native review"
|
|||
"Shape builder" = "Générateur de formes"
|
||||
"Shape tools" = "Outils de forme"
|
||||
"Share" = "Partager"
|
||||
"Share this file" = "Partager ce fichier"
|
||||
"Share: live session, export" = "Partager : session en direct, exportation"
|
||||
"Shared" = "Partagé"
|
||||
"Shared: copy its code from the file's menu" = "Partagé : copiez son code depuis le menu du fichier"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Le partage d’un fichier nécessite l’application de bureau ; ici, vous pouvez en rejoindre un."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Partage sur le réseau local (le partage en ligne est désactivé : aucun serveur n’est défini dans les Préférences)."
|
||||
"Sharing on your network · {people}" = "Partage sur votre réseau · {people}"
|
||||
"Sharing online · {people}" = "Partage en ligne · {people}"
|
||||
"Sharing stopped: the file stays on the server, open only to you" = "Partage arrêté : le fichier reste sur le serveur, accessible à vous seul"
|
||||
"Show" = "Afficher"
|
||||
"Show as grid" = "Afficher en grille"
|
||||
"Show as list" = "Afficher en liste"
|
||||
|
|
@ -1113,6 +1178,7 @@ status = "draft — needs native review"
|
|||
"Space evenly" = "Espace égal"
|
||||
"Space+drag" = "Espace+glisser"
|
||||
"Spacing" = "Espacement"
|
||||
"Spam, stolen work, abuse…" = "Spam, travail volé, abus…"
|
||||
"Splay" = "Évasement"
|
||||
"Spotlight me" = "Me mettre sous les projecteurs"
|
||||
"Spread" = "Étendue"
|
||||
|
|
@ -1126,11 +1192,14 @@ status = "draft — needs native review"
|
|||
"Start from a blank canvas" = "Commencer avec un canevas vide"
|
||||
"Start on path" = "Début sur le tracé"
|
||||
"Start point" = "Point de départ"
|
||||
"Start sharing" = "Commencer le partage"
|
||||
"Starting to share…" = "Démarrage du partage…"
|
||||
"Status" = "Statut"
|
||||
"Sticky" = "Collant"
|
||||
"Stop" = "Arrêter"
|
||||
"Stop following {name}" = "Ne plus suivre {name}"
|
||||
"Stop session" = "Arrêter la session"
|
||||
"Stop sharing" = "Arrêter le partage"
|
||||
"Stop spotlighting" = "Quitter les projecteurs"
|
||||
"Stop {n} {hex}" = "Arrêt {n} {hex}"
|
||||
"Stretch" = "Étirer"
|
||||
|
|
@ -1169,8 +1238,10 @@ status = "draft — needs native review"
|
|||
"Take the values from the first selected layer" = "Reprendre les valeurs du premier calque sélectionné"
|
||||
"Taper" = "Effilé"
|
||||
"Taper both ends" = "Effiler les deux extrémités"
|
||||
"Tell the server's operator what is wrong with it" = "Indiquez à l’opérateur du serveur ce qui ne va pas"
|
||||
"Temperature" = "Température"
|
||||
"Template" = "Modèle"
|
||||
"Terms" = "Conditions"
|
||||
"Text" = "Texte"
|
||||
"Text align" = "Alignement du texte"
|
||||
"Text content" = "Contenu du texte"
|
||||
|
|
@ -1179,20 +1250,31 @@ status = "draft — needs native review"
|
|||
"Texts" = "Textes"
|
||||
"Texture" = "Texture"
|
||||
"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Le canevas affiche les couleurs en sRGB ; les fichiers PNG, JPG et WebP exportés sont balisés Display P3."
|
||||
"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Le fichier est supprimé du serveur : plus personne ne pourra l’ouvrir ni le rejoindre depuis celui-ci, et c’est irréversible. Les copies ouvertes restent à ceux qui les ont."
|
||||
"The file now holds another library" = "Le fichier contient désormais une autre bibliothèque"
|
||||
"The file now holds another library (\"{name}\")" = "Le fichier contient désormais une autre bibliothèque (« {name} »)"
|
||||
"The files you shared or published online from this computer. They stay on their server until you remove them." = "Les fichiers que vous avez partagés ou publiés en ligne depuis cet ordinateur. Ils restent sur leur serveur jusqu’à ce que vous les retiriez."
|
||||
"The link carries the session's secret: share it only with people you invite." = "Le lien contient le secret de la session : ne le partagez qu’avec les personnes que vous invitez."
|
||||
"The name in your online files and on the server. The document inside keeps its own name." = "Le nom dans vos fichiers en ligne et sur le serveur. Le document qu’il contient garde son propre nom."
|
||||
"The selection uses several text styles: click to apply one" = "La sélection utilise plusieurs styles de texte : cliquez pour en appliquer un"
|
||||
"The server deletes this file if nobody opens it before {date}." = "Le serveur supprime ce fichier si personne ne l’ouvre avant le {date}."
|
||||
"The whole file" = "Le fichier entier"
|
||||
"Theme" = "Thème"
|
||||
"There's no destination for that action" = "Cette action n’a pas de destination"
|
||||
"Thickness" = "Épaisseur"
|
||||
"This branch can't be reviewed: {e}" = "Impossible de relire cette branche : {e}"
|
||||
"This branch was merged or archived." = "Cette branche a été fusionnée ou archivée."
|
||||
"This computer only" = "Cet ordinateur uniquement"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Cet ordinateur mémorise le jeton de propriétaire : le fichier se gère depuis la liste En ligne de l’écran d’accueil. Copiez-le pour gérer le fichier depuis un autre endroit : c’est un mot de passe pour ce fichier et il ne sera plus affiché."
|
||||
"This condition is edited as an expression" = "Cette condition se modifie en tant qu’expression"
|
||||
"This file" = "Ce fichier"
|
||||
"This file has unsaved changes" = "Ce fichier contient des modifications non enregistrées"
|
||||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Ce fichier est sur {server}, accessible à vous seul. Partagez-le pour laisser entrer d’autres personnes avec un nouveau code."
|
||||
"This frame is new on the branch." = "Ce cadre est nouveau dans la branche."
|
||||
"This frame isn't on the branch." = "Ce cadre n’est pas dans la branche."
|
||||
"This server is open to the public: no key needed." = "Ce serveur est ouvert au public : aucune clé nécessaire."
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Ce serveur exige sa clé : ajoutez ?key=… à l’adresse (`figcraft-server key` l’affiche sur la machine du serveur)."
|
||||
"This server takes files only with its key: see Advanced." = "Ce serveur n’accepte les fichiers qu’avec sa clé : voir Avancé."
|
||||
"Tidy up" = "Ranger"
|
||||
"Tile" = "Mosaïque"
|
||||
"Tile type" = "Type de mosaïque"
|
||||
|
|
@ -1214,10 +1296,12 @@ status = "draft — needs native review"
|
|||
"True" = "Vrai"
|
||||
"Truncate text" = "Tronquer le texte"
|
||||
"Truncate with an ellipsis" = "Tronquer avec des points de suspension"
|
||||
"Try again" = "Réessayer"
|
||||
"Try another search or filter." = "Essayez une autre recherche ou un autre filtre."
|
||||
"Two digits" = "Deux chiffres"
|
||||
"Type" = "Type"
|
||||
"Type settings" = "Réglages typographiques"
|
||||
"Type the join code you were given, or paste an invite link." = "Saisissez le code d’accès que l’on vous a donné, ou collez un lien d’invitation."
|
||||
"Typography" = "Typographie"
|
||||
"Underline" = "Souligné"
|
||||
"Undo takes back only your own changes; what others changed since stays." = "Annuler ne revient que sur vos propres modifications ; ce que les autres ont modifié depuis reste."
|
||||
|
|
@ -1289,11 +1373,13 @@ status = "draft — needs native review"
|
|||
"Wedge" = "Coin"
|
||||
"Weight" = "Graisse"
|
||||
"What changed since it was marked ready for dev" = "Ce qui a changé depuis le marquage prêt pour le dev"
|
||||
"What is wrong with this file? The server's operator gets your report." = "Qu’est-ce qui ne va pas avec ce fichier ? L’opérateur du serveur reçoit votre signalement."
|
||||
"What it's for" = "À quoi il sert"
|
||||
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "L’état d’origine de cette modification est inconnu : elle date d’avant que FigCraft ne conserve une copie des cadres prêts pour le dev. Résolvez la modification (ou marquez-la à nouveau comme prête) pour comparer à partir de maintenant."
|
||||
"While hovering" = "Pendant le survol"
|
||||
"While pressing" = "Pendant l’appui"
|
||||
"White" = "Blanc"
|
||||
"Who runs it, its terms and how long it keeps files" = "Qui le gère, ses conditions et combien de temps il conserve les fichiers"
|
||||
"Why it changed" = "Pourquoi cela a changé"
|
||||
"Widgets" = "Widgets"
|
||||
"Width" = "Largeur"
|
||||
|
|
@ -1305,8 +1391,10 @@ status = "draft — needs native review"
|
|||
"Yellow" = "Jaune"
|
||||
"Yesterday" = "Hier"
|
||||
"You" = "Vous"
|
||||
"You'll get the shared file as a new tab and edit it together." = "Vous recevrez le fichier partagé dans un nouvel onglet et le modifierez ensemble."
|
||||
"You're spotlighting: everyone follows your view" = "Vous êtes sous les projecteurs : tout le monde suit votre vue"
|
||||
"Your copy of the file stays open" = "Votre copie du fichier reste ouverte"
|
||||
"Your copy stays open; the file stays on the server" = "Votre copie reste ouverte ; le fichier reste sur le serveur"
|
||||
"Your name" = "Votre nom"
|
||||
"Your name in comments" = "Votre nom dans les commentaires"
|
||||
"Your system asks for reduced motion: animations stay off." = "Votre système demande de réduire les animations : elles restent désactivées."
|
||||
|
|
@ -1357,14 +1445,20 @@ status = "draft — needs native review"
|
|||
"{name} (you)" = "{name} (vous)"
|
||||
"{name} ({layers} layers)" = "{name} ({layers} calques)"
|
||||
"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}) : {min} – {max}, par défaut {default}. Double-cliquez sur le curseur pour revenir à la valeur du style ; Échap pendant le glissement annule."
|
||||
"{name} cleared from the list" = "{name} effacé de la liste"
|
||||
"{name} from {path}" = "{name} depuis {path}"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} est oublié sur cet ordinateur ; le fichier reste sur {server}"
|
||||
"{name} is on the server." = "{name} est sur le serveur."
|
||||
"{name} is spotlighting" = "{name} est sous les projecteurs"
|
||||
"{name} was removed from the server" = "{name} a été retiré du serveur"
|
||||
"{name} · deleted on main" = "{name} · supprimé dans le principal"
|
||||
"{name} · deleted on the branch" = "{name} · supprimé dans la branche"
|
||||
"{name} · not published" = "{name} · non publié"
|
||||
"{name} · on a server" = "{name} · sur un serveur"
|
||||
"{name} · published version {v}" = "{name} · version publiée {v}"
|
||||
"{name} · version {v} on the server" = "{name} · version {v} sur le serveur"
|
||||
"{name}, in a live session" = "{name}, dans une session en direct"
|
||||
"{name}, online, open only to you" = "{name}, en ligne, accessible à vous seul"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} sur {h}, sélectionné"
|
||||
"{n} added" = { one = "{n} ajouté", other = "{n} ajoutés" }
|
||||
"{n} autosave versions" = { one = "{n} version enregistrée automatiquement", other = "{n} versions enregistrées automatiquement" }
|
||||
|
|
@ -1402,6 +1496,9 @@ status = "draft — needs native review"
|
|||
"{page}, nothing selected" = "{page}, aucune sélection"
|
||||
"{profile} (current)" = "{profile} (actuel)"
|
||||
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} donne sa valeur ; {property ? \"a\" : \"b\"} choisit un texte. Tout autre {…} reste tel quel."
|
||||
"{server} can't say what became of your files (it is older than this app)." = "{server} ne peut pas dire ce que sont devenus vos fichiers (il est plus ancien que cette application)."
|
||||
"{server} · No longer on the server" = "{server} · N’est plus sur le serveur"
|
||||
"{server} · {shared} · Opened {date}" = "{server} · {shared} · Ouvert le {date}"
|
||||
"{tool} hold {key}" = "{tool} maintenir {key}"
|
||||
"{value} isn't a colour" = "{value} n’est pas une couleur"
|
||||
"{when} · edited" = "{when} · modifié"
|
||||
|
|
@ -1460,13 +1557,20 @@ status = "draft — needs native review"
|
|||
"collab.cursor" = "Afficher le curseur à"
|
||||
"collab.cursorChat" = "Chat au curseur"
|
||||
"collab.diff" = "Afficher les modifications synchronisées"
|
||||
"collab.files" = "Vos fichiers en ligne"
|
||||
"collab.follow" = "Suivre"
|
||||
"collab.forgetFile" = "Oublier un fichier en ligne"
|
||||
"collab.host" = "Démarrer une session en direct"
|
||||
"collab.join" = "Rejoindre une session en direct"
|
||||
"collab.leave" = "Quitter la session en direct"
|
||||
"collab.manageFile" = "Gérer le fichier publié"
|
||||
"collab.openFile" = "Ouvrir un fichier en ligne"
|
||||
"collab.peers" = "Personnes dans ce fichier"
|
||||
"collab.publish" = "Publier sur un serveur"
|
||||
"collab.registerLinks" = "Ouvrir les liens d’invitation dans FigCraft"
|
||||
"collab.report" = "Signaler le fichier à l’opérateur du serveur"
|
||||
"collab.serverInfo" = "Informations sur le serveur"
|
||||
"collab.share" = "Partager avec un code d’accès"
|
||||
"collab.spotlight" = "Me mettre sous les projecteurs"
|
||||
"collab.stop" = "Arrêter la session en direct"
|
||||
"command.batch" = "Exécuter plusieurs commandes"
|
||||
|
|
@ -1685,6 +1789,7 @@ status = "draft — needs native review"
|
|||
"pref.language" = "Langue de l’interface"
|
||||
"pref.nudge" = "Pas de décalage"
|
||||
"pref.numberKeysOpacity" = "Utiliser les touches numériques pour l’opacité"
|
||||
"pref.publishServer" = "Serveur pour le partage en ligne"
|
||||
"pref.reduceMotion" = "Réduire les animations"
|
||||
"pref.renameDuplicates" = "Renommer les calques dupliqués"
|
||||
"pref.showDimensions" = "Afficher les dimensions sur les objets"
|
||||
|
|
|
|||
|
|
@ -15,14 +15,17 @@ status = "draft — needs native review"
|
|||
"(no longer published)" = "(公開終了)"
|
||||
"({n} warnings: some entries were skipped)" = { other = "(警告 {n} 件:一部の項目をスキップ)" }
|
||||
"+ Create variable" = "+ 変数を作成"
|
||||
".fig (experimental)" = ".fig(試験的)"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:PR {prs} 件、コミット {commits} 件、+{added} / −{deleted} 行(Δ {delta})、バイナリアセット +{bin_added} / −{bin_deleted}、{first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "このファイルのコレクションとそのモード・変数:モードごとに値を上書きし、独自の変数を追加できる"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "スプリングは静止するまで再生。長さは質量・剛性・減衰で決まる"
|
||||
"About" = "情報"
|
||||
"About FigCraft" = "FigCraft について"
|
||||
"About this server" = "このサーバーについて"
|
||||
"Action" = "アクション"
|
||||
"Actions" = "アクション"
|
||||
"Actions for {name}" = "{name} の操作"
|
||||
"Actions…" = "アクション…"
|
||||
"Active" = "アクティブ"
|
||||
"Actual size (100%)" = "実際のサイズ(100%)"
|
||||
|
|
@ -64,6 +67,7 @@ status = "draft — needs native review"
|
|||
"Added the library \"{name}\"" = "ライブラリ「{name}」を追加済み"
|
||||
"Adjust" = "調整"
|
||||
"Adjustment presets" = "調整プリセット"
|
||||
"Advanced" = "詳細設定"
|
||||
"Advanced stroke" = "詳細な線"
|
||||
"After delay" = "遅延後"
|
||||
"After delay {ms}ms" = "{ms}ms 遅延後"
|
||||
|
|
@ -87,14 +91,19 @@ status = "draft — needs native review"
|
|||
"All variables" = "すべての変数"
|
||||
"Alpha" = "アルファ"
|
||||
"Alt+drag" = "Alt+ドラッグ"
|
||||
"An invite link that opens FigCraft and joins" = "FigCraft を開いて参加する招待リンク"
|
||||
"Anchor" = "アンカー"
|
||||
"Android" = "Android"
|
||||
"Angular" = "角度"
|
||||
"Animation" = "アニメーション"
|
||||
"Annotations" = "注釈"
|
||||
"Annotations shown on the canvas" = "キャンバスに表示される注釈"
|
||||
"Another server, or one that needs its key. The usual server is set in Preferences." = "別のサーバー、またはキーが必要なサーバー。通常のサーバーは環境設定で指定。"
|
||||
"Any status" = "すべてのステータス"
|
||||
"Anyone on your network with this code can edit the file, while this app stays open." = "このアプリを開いている間、同じネットワーク内でこのコードを持つ人は誰でもファイルを編集可能。"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "編集リンクを持つ人はファイルを変更でき、閲覧リンクでは表示のみ可能。リンクには秘密情報が含まれるため、パスワードと同様に扱うこと。リンクの追加と取り消しは `figcraft-server links` で行う。"
|
||||
"Anyone with this code can edit the file." = "このコードを持つ人は誰でもファイルを編集可能。"
|
||||
"Anyone with this code can look at the file, not change it." = "このコードを持つ人は誰でもファイルを閲覧可能(変更は不可)。"
|
||||
"Appearance" = "外観"
|
||||
"Apply a component property" = "コンポーネントプロパティを適用"
|
||||
"Apply variable" = "変数を適用"
|
||||
|
|
@ -109,6 +118,7 @@ status = "draft — needs native review"
|
|||
"Arrange" = "配置"
|
||||
"Arrow" = "矢印"
|
||||
"As typed" = "入力どおり"
|
||||
"Asking the server…" = "サーバーに問い合わせ中…"
|
||||
"Assets" = "アセット"
|
||||
"Auto" = "自動"
|
||||
"Auto (space around)" = "自動(周囲に間隔)"
|
||||
|
|
@ -121,6 +131,7 @@ status = "draft — needs native review"
|
|||
"Auto layout" = "オートレイアウト"
|
||||
"Auto rows" = "自動行"
|
||||
"Auto width" = "幅を自動調整"
|
||||
"Back to the FigCraft server" = "FigCraft のサーバーに戻す"
|
||||
"Back to the font's value" = "フォントの値に戻す"
|
||||
"Back" = "戻る"
|
||||
"Back to Ready for dev" = "開発準備完了に戻す"
|
||||
|
|
@ -176,6 +187,10 @@ status = "draft — needs native review"
|
|||
"C or Shift+C" = "C または Shift+C"
|
||||
"C or ⇧C" = "C または ⇧C"
|
||||
"CMYK colour" = "CMYK カラー"
|
||||
"Can view" = "閲覧可"
|
||||
"Checking the server…" = "サーバーを確認中…"
|
||||
"Clear from this list" = "このリストから消去"
|
||||
"Code or invite link" = "コードまたは招待リンク"
|
||||
"Color profile: {name}" = "カラープロファイル:{name}"
|
||||
"CSS custom properties…" = "CSS カスタムプロパティ…"
|
||||
"Can edit" = "編集可"
|
||||
|
|
@ -282,11 +297,13 @@ status = "draft — needs native review"
|
|||
"Copy as CSS" = "CSS としてコピー"
|
||||
"Copy as PNG" = "PNG としてコピー"
|
||||
"Copy as SVG" = "SVG としてコピー"
|
||||
"Copy code" = "コードをコピー"
|
||||
"Copy link" = "リンクをコピー"
|
||||
"Copy the {label}" = "{label}をコピー"
|
||||
"Copy {value}" = "{value} をコピー"
|
||||
"Corner radius" = "角の半径"
|
||||
"Corner smoothing" = "角のスムージング"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "{server} に接続できない:このコンピューターが覚えている内容を表示中。"
|
||||
"Count" = "数"
|
||||
"Create" = "作成"
|
||||
"Create a variable collection first" = "先に変数コレクションを作成"
|
||||
|
|
@ -403,6 +420,7 @@ status = "draft — needs native review"
|
|||
"Empty" = "空"
|
||||
"End" = "終了"
|
||||
"End point" = "終点"
|
||||
"Ends the session; everyone keeps their copy" = "セッションを終了。各自のコピーは残る"
|
||||
"Enter" = "Enter"
|
||||
"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "figcraft-server のアドレスを入力し、「参照」を押すと、提供されているプラグインが表示される。"
|
||||
"Enter to save · Esc to cancel" = "Enter で保存 · Esc でキャンセル"
|
||||
|
|
@ -428,6 +446,7 @@ status = "draft — needs native review"
|
|||
"Export {n} layers" = { other = "{n} 個のレイヤーを書き出し" }
|
||||
"Exported {file}" = "{file} を書き出し済み"
|
||||
"Exported {name}" = "{name} を書き出し済み"
|
||||
"Exported {name}; {n} kinds of content were left out" = { other = "{name} を書き出し済み。{n} 種類のコンテンツは含まれていない" }
|
||||
"Exported {n} files" = { other = "{n} 個のファイルを書き出し済み" }
|
||||
"Export…" = "書き出し…"
|
||||
"Expose properties to instances" = "プロパティをインスタンスに公開"
|
||||
|
|
@ -442,6 +461,9 @@ status = "draft — needs native review"
|
|||
"Fetching from the server…" = "サーバーから取得中…"
|
||||
"File" = "ファイル"
|
||||
"File name" = "ファイル名"
|
||||
"File published" = "ファイルを公開済み"
|
||||
"Files aren't end-to-end encrypted: the server's operator can read them." = "ファイルはエンドツーエンドで暗号化されない:サーバーの運営者は内容を読める。"
|
||||
"Files nobody opens for {n} days are deleted." = { other = "{n} 日間誰も開かなかったファイルは削除される。" }
|
||||
"Fill" = "塗り"
|
||||
"Fill (fr)" = "フィル(fr)"
|
||||
"Fill container" = "コンテナに合わせる"
|
||||
|
|
@ -484,6 +506,7 @@ status = "draft — needs native review"
|
|||
"Font not installed" = "フォントが未インストール"
|
||||
"Font size" = "フォントサイズ"
|
||||
"Font style" = "フォントスタイル"
|
||||
"Forget on this computer" = "このコンピューターで記録を消去"
|
||||
"Fractions" = "分数"
|
||||
"Frame" = "フレーム"
|
||||
"Frame selection" = "選択範囲をフレームで囲む"
|
||||
|
|
@ -497,6 +520,7 @@ status = "draft — needs native review"
|
|||
"Gap between columns" = "列の間隔"
|
||||
"Gap between items" = "アイテムの間隔"
|
||||
"Gap between rows" = "行の間隔"
|
||||
"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "一般公開されているサーバーのアドレス、またはキー付きのサーバーのアドレスを入力(`figcraft-server key` で表示)。"
|
||||
"Glass" = "ガラス"
|
||||
"Go to main component" = "メインコンポーネントへ移動"
|
||||
"Go to {name}" = "{name} へ移動"
|
||||
|
|
@ -520,6 +544,7 @@ status = "draft — needs native review"
|
|||
"Help" = "ヘルプ"
|
||||
"Help and resources" = "ヘルプとリソース"
|
||||
"Help page" = "ヘルプページ"
|
||||
"Here: {names}" = "参加中:{names}"
|
||||
"Hidden" = "非表示"
|
||||
"Hide contents" = "コンテンツを非表示"
|
||||
"Hide from publishing" = "公開対象から除外"
|
||||
|
|
@ -539,6 +564,7 @@ status = "draft — needs native review"
|
|||
"Horizontal hex" = "横向き六角形"
|
||||
"Horizontal padding" = "左右のパディング"
|
||||
"Horizontal spacing" = "水平方向の間隔"
|
||||
"Hosted by {operator}" = "運営:{operator}"
|
||||
"Hover a layer to see its properties" = "レイヤーにポインターを合わせるとプロパティを表示"
|
||||
"Hover other layers to measure the distance" = "他のレイヤーにポインターを合わせると距離を測定"
|
||||
"Hue" = "色相"
|
||||
|
|
@ -589,8 +615,11 @@ status = "draft — needs native review"
|
|||
"Invite link copied" = "招待リンクをコピー済み"
|
||||
"It asks permission to:" = "次の権限を求めている:"
|
||||
"Join" = "参加"
|
||||
"Join code copied" = "参加コードをコピー済み"
|
||||
"Join code or invite link" = "参加コードまたは招待リンク"
|
||||
"Join live session" = "ライブセッションに参加"
|
||||
"Join us on Discord" = "Discord に参加する"
|
||||
"Join with a code" = "コードで参加"
|
||||
"Joined" = "参加済み"
|
||||
"Joined {file}" = "{file} に参加済み"
|
||||
"Joining…" = "参加中…"
|
||||
|
|
@ -653,6 +682,7 @@ status = "draft — needs native review"
|
|||
"Live session" = "ライブセッション"
|
||||
"Live · {people}" = "ライブ · {people}"
|
||||
"Local components" = "ローカルコンポーネント"
|
||||
"Local network" = "ローカルネットワーク"
|
||||
"Local styles" = "ローカルスタイル"
|
||||
"Local variables" = "ローカル変数"
|
||||
"Lock/Unlock" = "ロック/ロック解除"
|
||||
|
|
@ -742,6 +772,7 @@ status = "draft — needs native review"
|
|||
"No conflicts." = "競合なし。"
|
||||
"No contributor data was built into this copy." = "このコピーには貢献者データが含まれていない。"
|
||||
"No fonts match" = "一致するフォントなし"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "参加コードなし(サーバーが参加コードより古いか、このアドレスはコードにできない):代わりにリンクを共有すること。"
|
||||
"No layer changes to show." = "表示するレイヤーの変更なし。"
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "ライブラリはまだない。ライブラリとして公開された .figcraft ファイルを追加するか、チームライブラリのリンクを貼り付ける。"
|
||||
"No list" = "リストなし"
|
||||
|
|
@ -780,9 +811,11 @@ status = "draft — needs native review"
|
|||
"Not in this font" = "このフォントにはない"
|
||||
"Not properties, kept as written: {names}" = "プロパティではないため記述どおりに保持:{names}"
|
||||
"Not read yet" = "未読"
|
||||
"Not shared" = "共有していない"
|
||||
"Note {n}" = "メモ {n}"
|
||||
"Nothing changed since this was marked ready for dev." = "開発準備完了にしてから変更なし。"
|
||||
"Nothing here yet. Set a frame's status to list it." = "まだ何もない。フレームのステータスを設定すると一覧に表示される。"
|
||||
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "オンラインのファイルはまだない。ファイルの「共有」からオンラインにすると、ここに表示される。"
|
||||
"Nothing to export" = "書き出すものがない"
|
||||
"Now" = "現在"
|
||||
"Nudge (Shift: big nudge)" = "微調整(Shift:大きく微調整)"
|
||||
|
|
@ -799,6 +832,9 @@ status = "draft — needs native review"
|
|||
"On click" = "クリック時"
|
||||
"On drag" = "ドラッグ時"
|
||||
"On this page" = "このページ"
|
||||
"Online" = "オンライン"
|
||||
"Online files" = "オンラインファイル"
|
||||
"Online sharing is off: files are shared on the local network only." = "オンライン共有はオフ:ファイルはローカルネットワークでのみ共有される。"
|
||||
"Only the first changed layers are listed." = "変更されたレイヤーは最初の分のみ表示。"
|
||||
"Only the first {n} changed layers are listed." = "変更されたレイヤーは最初の {n} 個のみ表示。"
|
||||
"Only your threads" = "自分のスレッドのみ"
|
||||
|
|
@ -822,6 +858,9 @@ status = "draft — needs native review"
|
|||
"Open|comments" = "未解決"
|
||||
"Options" = "オプション"
|
||||
"Orange" = "オレンジ"
|
||||
"Other apps may not open every .fig file exported here." = "ここで書き出した .fig ファイルは、他のアプリで開けない場合がある。"
|
||||
"Others lose access and keep their copy; the file stays on the server for you" = "他の人はアクセスできなくなり、各自のコピーは残る。ファイルはあなた用にサーバーに残る"
|
||||
"Others type this under Share ▸ Join with a code" = "他の人は「共有 ▸ コードで参加」でこれを入力"
|
||||
"Outline stroke" = "線のアウトライン化"
|
||||
"Outline text" = "テキストのアウトライン化"
|
||||
"Outlines" = "アウトライン"
|
||||
|
|
@ -831,6 +870,8 @@ status = "draft — needs native review"
|
|||
"Overlay position" = "オーバーレイの位置"
|
||||
"Overridden here (the collection it extends has another value)" = "ここで上書き(拡張元のコレクションには別の値がある)"
|
||||
"Overrides" = "オーバーライド"
|
||||
"Owner token" = "所有者トークン"
|
||||
"Owner token copied" = "所有者トークンをコピー済み"
|
||||
"PDF print options" = "PDF 印刷オプション"
|
||||
"PDF print settings" = "PDF 印刷設定"
|
||||
"Padding" = "パディング"
|
||||
|
|
@ -857,6 +898,8 @@ status = "draft — needs native review"
|
|||
"Pen" = "ペン"
|
||||
"Pencil" = "鉛筆"
|
||||
"People in this file" = "このファイルにいるユーザー"
|
||||
"People join from anywhere with a code. The file is kept on {server} while you share it." = "コードがあればどこからでも参加可能。共有している間、ファイルは {server} に保管される。"
|
||||
"People on your network join with a code. Nothing goes through a server." = "同じネットワークの人がコードで参加。サーバーは経由しない。"
|
||||
"Phone" = "スマートフォン"
|
||||
"Pick color" = "色を抽出"
|
||||
"Pick color (I)" = "色を抽出(I)"
|
||||
|
|
@ -877,6 +920,7 @@ status = "draft — needs native review"
|
|||
"Points" = "ポイント"
|
||||
"Polygon" = "多角形"
|
||||
"Polygons" = "多角形"
|
||||
"Port" = "ポート"
|
||||
"Position" = "位置"
|
||||
"Post" = "投稿"
|
||||
"Preferences" = "環境設定"
|
||||
|
|
@ -905,12 +949,14 @@ status = "draft — needs native review"
|
|||
"Prototype settings" = "プロトタイプの設定"
|
||||
"Publish" = "公開"
|
||||
"Publish changes" = "変更を公開"
|
||||
"Publish to another server…" = "別のサーバーに公開…"
|
||||
"Publish to server" = "サーバーに公開"
|
||||
"Published version {v}" = "バージョン {v} を公開済み"
|
||||
"Published version {v} to the server" = "バージョン {v} をサーバーに公開済み"
|
||||
"Published version {v}: save the file so other files can use it" = "バージョン {v} を公開済み:他のファイルで使えるようにファイルを保存"
|
||||
"Published · {people}" = "公開済み · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "サーバーへの公開にはデスクトップアプリが必要。ブラウザーではサーバーのファイルに参加できる"
|
||||
"Publishing with the server's key." = "サーバーのキーで公開。"
|
||||
"Publishing…" = "公開中…"
|
||||
"Push" = "プッシュ"
|
||||
"Push changes to main component" = "変更をメインコンポーネントにプッシュ"
|
||||
|
|
@ -938,6 +984,8 @@ status = "draft — needs native review"
|
|||
"Remove auto layout" = "オートレイアウトを解除"
|
||||
"Remove dev resource" = "開発リソースを削除"
|
||||
"Remove export setting" = "書き出し設定を削除"
|
||||
"Remove from server" = "サーバーから削除"
|
||||
"Remove from server…" = "サーバーから削除…"
|
||||
"Remove guide" = "ガイドを削除"
|
||||
"Remove interaction" = "インタラクションを削除"
|
||||
"Remove library from this file" = "このファイルからライブラリを削除"
|
||||
|
|
@ -951,7 +999,10 @@ status = "draft — needs native review"
|
|||
"Remove text from path" = "テキストをパスから外す"
|
||||
"Remove the else and its actions" = "「それ以外」とそのアクションを削除"
|
||||
"Remove this condition and its actions" = "この条件とそのアクションを削除"
|
||||
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "このファイルをサーバーから削除しますか? サーバーからの共有や再オープンはできなくなる。手元のコピーは開いたまま。"
|
||||
"Remove {name} from {server}?" = "{name} を {server} から削除しますか?"
|
||||
"Removed" = "削除"
|
||||
"Removed from the server" = "サーバーから削除済み"
|
||||
"Removed since it was marked" = "マーク後に削除"
|
||||
"Rename" = "名前を変更"
|
||||
"Rename duplicated layers" = "複製したレイヤーの名前を変更"
|
||||
|
|
@ -959,6 +1010,8 @@ status = "draft — needs native review"
|
|||
"Rename group" = "グループ名を変更"
|
||||
"Rename layers" = "レイヤー名を変更"
|
||||
"Rename mode" = "モード名を変更"
|
||||
"Rename on the server" = "サーバー上の名前を変更"
|
||||
"Rename on the server…" = "サーバー上の名前を変更…"
|
||||
"Rename page" = "ページ名を変更"
|
||||
"Rename to" = "新しい名前"
|
||||
"Replace all" = "すべて置換"
|
||||
|
|
@ -966,6 +1019,8 @@ status = "draft — needs native review"
|
|||
"Replace with" = "置換後の文字列"
|
||||
"Replace with a new interaction" = "新しいインタラクションで置き換え"
|
||||
"Reply" = "返信"
|
||||
"Report" = "報告"
|
||||
"Report this file…" = "このファイルを報告…"
|
||||
"Reset" = "リセット"
|
||||
"Reset adjustments" = "調整をリセット"
|
||||
"Reset all changes" = "すべての変更をリセット"
|
||||
|
|
@ -1055,9 +1110,11 @@ status = "draft — needs native review"
|
|||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "選択ボックス、{w} × {h}、位置 {x}, {y}:矢印キーで移動、Shift+矢印キーまたは {cmd}+矢印キーでサイズ変更、Enter で選択、Escape でキャンセル"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "選択ボックス:矢印キーで移動、Shift+矢印キーまたは {cmd}+矢印キーでサイズ変更、Enter で選択、Escape でキャンセル"
|
||||
"Send backward" = "背面へ"
|
||||
"Send report" = "報告を送信"
|
||||
"Send to back" = "最背面へ"
|
||||
"Server address" = "サーバーのアドレス"
|
||||
"Server address (ws://host:47900/)" = "サーバーのアドレス(ws://host:47900/)"
|
||||
"Server for online sharing" = "オンライン共有用のサーバー"
|
||||
"Set as default" = "デフォルトに設定"
|
||||
"Set variable" = "変数を設定"
|
||||
"Set variable mode" = "変数モードを設定"
|
||||
|
|
@ -1068,7 +1125,15 @@ status = "draft — needs native review"
|
|||
"Shape builder" = "シェイプビルダー"
|
||||
"Shape tools" = "シェイプツール"
|
||||
"Share" = "共有"
|
||||
"Share this file" = "このファイルを共有"
|
||||
"Share: live session, export" = "共有:ライブセッション、書き出し"
|
||||
"Shared" = "共有中"
|
||||
"Shared: copy its code from the file's menu" = "共有済み:コードはファイルのメニューからコピー"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "ファイルの共有にはデスクトップアプリが必要。ここでは参加のみ可能。"
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "ローカルネットワークで共有(オンライン共有はオフ:環境設定でサーバーが未設定)。"
|
||||
"Sharing on your network · {people}" = "ネットワークで共有中 · {people}"
|
||||
"Sharing online · {people}" = "オンラインで共有中 · {people}"
|
||||
"Sharing stopped: the file stays on the server, open only to you" = "共有を停止:ファイルはサーバーに残り、開けるのはあなただけ"
|
||||
"Show" = "表示"
|
||||
"Show as grid" = "グリッドで表示"
|
||||
"Show as list" = "リストで表示"
|
||||
|
|
@ -1115,6 +1180,7 @@ status = "draft — needs native review"
|
|||
"Space evenly" = "均等に配置"
|
||||
"Space+drag" = "Space+ドラッグ"
|
||||
"Spacing" = "間隔"
|
||||
"Spam, stolen work, abuse…" = "スパム、盗用、迷惑行為…"
|
||||
"Splay" = "広がり"
|
||||
"Spotlight me" = "自分をスポットライト"
|
||||
"Spread" = "スプレッド"
|
||||
|
|
@ -1128,11 +1194,14 @@ status = "draft — needs native review"
|
|||
"Start from a blank canvas" = "空白のキャンバスから始める"
|
||||
"Start on path" = "パス上の開始位置"
|
||||
"Start point" = "始点"
|
||||
"Start sharing" = "共有を開始"
|
||||
"Starting to share…" = "共有を開始中…"
|
||||
"Status" = "ステータス"
|
||||
"Sticky" = "固定"
|
||||
"Stop" = "停止"
|
||||
"Stop following {name}" = "{name} のフォローを停止"
|
||||
"Stop session" = "セッションを停止"
|
||||
"Stop sharing" = "共有を停止"
|
||||
"Stop spotlighting" = "スポットライトを停止"
|
||||
"Stop {n} {hex}" = "分岐点 {n} {hex}"
|
||||
"Stretch" = "伸縮"
|
||||
|
|
@ -1171,8 +1240,10 @@ status = "draft — needs native review"
|
|||
"Take the values from the first selected layer" = "最初に選択したレイヤーの値を使用"
|
||||
"Taper" = "先細り"
|
||||
"Taper both ends" = "両端を先細り"
|
||||
"Tell the server's operator what is wrong with it" = "何が問題かをサーバーの運営者に伝える"
|
||||
"Temperature" = "色温度"
|
||||
"Template" = "テンプレート"
|
||||
"Terms" = "利用規約"
|
||||
"Text" = "テキスト"
|
||||
"Text align" = "テキストの配置"
|
||||
"Text content" = "テキストの内容"
|
||||
|
|
@ -1181,20 +1252,31 @@ status = "draft — needs native review"
|
|||
"Texts" = "テキスト"
|
||||
"Texture" = "テクスチャ"
|
||||
"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "キャンバスは sRGB で色をプレビュー。書き出した PNG、JPG、WebP ファイルには Display P3 のタグが付く。"
|
||||
"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "ファイルはサーバーから削除される:サーバーから開いたり参加したりすることは誰にもできなくなり、元に戻せない。開いているコピーは各自の手元に残る。"
|
||||
"The file now holds another library" = "このファイルは別のライブラリになっている"
|
||||
"The file now holds another library (\"{name}\")" = "このファイルは別のライブラリ(「{name}」)になっている"
|
||||
"The files you shared or published online from this computer. They stay on their server until you remove them." = "このコンピューターからオンラインで共有または公開したファイル。削除するまでサーバーに残る。"
|
||||
"The link carries the session's secret: share it only with people you invite." = "リンクにはセッションの秘密情報が含まれる:招待する人とだけ共有すること。"
|
||||
"The name in your online files and on the server. The document inside keeps its own name." = "オンラインファイルの一覧とサーバーでの名前。中のドキュメントの名前は変わらない。"
|
||||
"The selection uses several text styles: click to apply one" = "選択範囲に複数のテキストスタイルが使われている:クリックして 1 つを適用"
|
||||
"The server deletes this file if nobody opens it before {date}." = "{date} までに誰も開かなければ、サーバーはこのファイルを削除する。"
|
||||
"The whole file" = "ファイル全体"
|
||||
"Theme" = "テーマ"
|
||||
"There's no destination for that action" = "そのアクションの遷移先がない"
|
||||
"Thickness" = "太さ"
|
||||
"This branch can't be reviewed: {e}" = "このブランチはレビューできない:{e}"
|
||||
"This branch was merged or archived." = "このブランチはマージまたはアーカイブ済み。"
|
||||
"This computer only" = "このコンピューターのみ"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "このコンピューターが所有者トークンを記憶するため、ファイルはホーム画面の「オンライン」リストから管理できる。別の場所から管理する場合はコピーしておくこと:このファイルのパスワードにあたり、再表示されない。"
|
||||
"This condition is edited as an expression" = "この条件は式として編集されている"
|
||||
"This file" = "このファイル"
|
||||
"This file has unsaved changes" = "このファイルには未保存の変更がある"
|
||||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "このファイルは {server} にあり、開けるのはあなただけ。共有すると、新しいコードで他の人が参加可能。"
|
||||
"This frame is new on the branch." = "このフレームはブランチで新規作成。"
|
||||
"This frame isn't on the branch." = "このフレームはブランチにない。"
|
||||
"This server is open to the public: no key needed." = "このサーバーは一般公開されている:キーは不要。"
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "このサーバーにはキーが必要:アドレスに ?key=… を追加(サーバーのマシンで `figcraft-server key` を実行すると表示)。"
|
||||
"This server takes files only with its key: see Advanced." = "このサーバーはキーがないとファイルを受け付けない:「詳細設定」を参照。"
|
||||
"Tidy up" = "整列"
|
||||
"Tile" = "タイル"
|
||||
"Tile type" = "タイルの種類"
|
||||
|
|
@ -1216,10 +1298,12 @@ status = "draft — needs native review"
|
|||
"True" = "true"
|
||||
"Truncate text" = "テキストを切り詰める"
|
||||
"Truncate with an ellipsis" = "省略記号で切り詰める"
|
||||
"Try again" = "再試行"
|
||||
"Try another search or filter." = "別の検索語やフィルターを試す。"
|
||||
"Two digits" = "2 桁"
|
||||
"Type" = "種類"
|
||||
"Type settings" = "文字の設定"
|
||||
"Type the join code you were given, or paste an invite link." = "受け取った参加コードを入力するか、招待リンクを貼り付ける。"
|
||||
"Typography" = "タイポグラフィ"
|
||||
"Underline" = "下線"
|
||||
"Undo takes back only your own changes; what others changed since stays." = "取り消しで戻るのは自分の変更のみ。その後に他の人が加えた変更は残る。"
|
||||
|
|
@ -1291,11 +1375,13 @@ status = "draft — needs native review"
|
|||
"Wedge" = "くさび"
|
||||
"Weight" = "太さ"
|
||||
"What changed since it was marked ready for dev" = "開発準備完了にしてからの変更内容"
|
||||
"What is wrong with this file? The server's operator gets your report." = "このファイルの何が問題ですか? 報告はサーバーの運営者に届く。"
|
||||
"What it's for" = "用途"
|
||||
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "変更前の状態は不明:FigCraft が開発準備完了フレームのコピーを保存するようになる前に変更された。変更を解決する(または再度準備完了にする)と、今後はそこから比較する。"
|
||||
"While hovering" = "ホバー中"
|
||||
"While pressing" = "押している間"
|
||||
"White" = "白"
|
||||
"Who runs it, its terms and how long it keeps files" = "運営者、利用規約、ファイルの保管期間"
|
||||
"Why it changed" = "変更理由"
|
||||
"Widgets" = "ウィジェット"
|
||||
"Width" = "幅"
|
||||
|
|
@ -1307,8 +1393,10 @@ status = "draft — needs native review"
|
|||
"Yellow" = "黄"
|
||||
"Yesterday" = "昨日"
|
||||
"You" = "自分"
|
||||
"You'll get the shared file as a new tab and edit it together." = "共有ファイルが新しいタブで開き、一緒に編集できる。"
|
||||
"You're spotlighting: everyone follows your view" = "スポットライト中:全員があなたのビューをフォロー"
|
||||
"Your copy of the file stays open" = "あなたのファイルのコピーは開いたまま"
|
||||
"Your copy stays open; the file stays on the server" = "手元のコピーは開いたまま。ファイルはサーバーに残る"
|
||||
"Your name" = "あなたの名前"
|
||||
"Your name in comments" = "コメントに表示する名前"
|
||||
"Your system asks for reduced motion: animations stay off." = "システムで動きを減らす設定がオンのため、アニメーションはオフのまま。"
|
||||
|
|
@ -1359,14 +1447,20 @@ status = "draft — needs native review"
|
|||
"{name} (you)" = "{name}(自分)"
|
||||
"{name} ({layers} layers)" = "{name}({layers} 個のレイヤー)"
|
||||
"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name}({tag}):{min} – {max}、デフォルト {default}。スライダーをダブルクリックするとスタイルの値に戻る。ドラッグ中に Escape でキャンセル。"
|
||||
"{name} cleared from the list" = "{name} をリストから消去済み"
|
||||
"{name} from {path}" = "{path} の {name}"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} の記録をこのコンピューターから消去済み。ファイルは {server} に残る"
|
||||
"{name} is on the server." = "{name} はサーバーにある。"
|
||||
"{name} is spotlighting" = "{name} がスポットライト中"
|
||||
"{name} was removed from the server" = "{name} をサーバーから削除済み"
|
||||
"{name} · deleted on main" = "{name} · メインで削除"
|
||||
"{name} · deleted on the branch" = "{name} · ブランチで削除"
|
||||
"{name} · not published" = "{name} · 未公開"
|
||||
"{name} · on a server" = "{name} · サーバー上"
|
||||
"{name} · published version {v}" = "{name} · 公開バージョン {v}"
|
||||
"{name} · version {v} on the server" = "{name} · サーバー上のバージョン {v}"
|
||||
"{name}, in a live session" = "{name}、ライブセッション中"
|
||||
"{name}, online, open only to you" = "{name}、オンライン、開けるのはあなただけ"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}、{kind}、{w} × {h}、選択中"
|
||||
"{n} added" = { other = "{n} 件追加" }
|
||||
"{n} autosave versions" = { other = "自動保存バージョン {n} 件" }
|
||||
|
|
@ -1404,6 +1498,9 @@ status = "draft — needs native review"
|
|||
"{page}, nothing selected" = "{page}、選択なし"
|
||||
"{profile} (current)" = "{profile}(現在)"
|
||||
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} はその値になり、{property ? \"a\" : \"b\"} は文字列を選ぶ。それ以外の {…} は記述どおりに残る。"
|
||||
"{server} can't say what became of your files (it is older than this app)." = "{server} はファイルの状況を返せない(このアプリより古いサーバー)。"
|
||||
"{server} · No longer on the server" = "{server} · サーバーにもうない"
|
||||
"{server} · {shared} · Opened {date}" = "{server} · {shared} · {date} に開いた"
|
||||
"{tool} hold {key}" = "{tool} {key} を長押し"
|
||||
"{value} isn't a colour" = "{value} はカラーではない"
|
||||
"{when} · edited" = "{when} · 編集済み"
|
||||
|
|
@ -1462,13 +1559,20 @@ status = "draft — needs native review"
|
|||
"collab.cursor" = "カーソルの位置を表示"
|
||||
"collab.cursorChat" = "カーソルチャット"
|
||||
"collab.diff" = "同期した変更を表示"
|
||||
"collab.files" = "あなたのオンラインファイル"
|
||||
"collab.follow" = "フォロー"
|
||||
"collab.forgetFile" = "オンラインファイルの記録を消去"
|
||||
"collab.host" = "ライブセッションを開始"
|
||||
"collab.join" = "ライブセッションに参加"
|
||||
"collab.leave" = "ライブセッションから退出"
|
||||
"collab.manageFile" = "公開したファイルを管理"
|
||||
"collab.openFile" = "オンラインファイルを開く"
|
||||
"collab.peers" = "このファイルにいるユーザー"
|
||||
"collab.publish" = "サーバーに公開"
|
||||
"collab.registerLinks" = "招待リンクを FigCraft で開く"
|
||||
"collab.report" = "ファイルをサーバーの運営者に報告"
|
||||
"collab.serverInfo" = "サーバー情報"
|
||||
"collab.share" = "参加コードで共有"
|
||||
"collab.spotlight" = "自分をスポットライト"
|
||||
"collab.stop" = "ライブセッションを停止"
|
||||
"command.batch" = "複数のコマンドを実行"
|
||||
|
|
@ -1687,6 +1791,7 @@ status = "draft — needs native review"
|
|||
"pref.language" = "インターフェースの言語"
|
||||
"pref.nudge" = "微調整の量"
|
||||
"pref.numberKeysOpacity" = "数字キーで不透明度を変更"
|
||||
"pref.publishServer" = "オンライン共有用のサーバー"
|
||||
"pref.reduceMotion" = "動きを減らす"
|
||||
"pref.renameDuplicates" = "複製したレイヤーの名前を変更"
|
||||
"pref.showDimensions" = "オブジェクトに寸法を表示"
|
||||
|
|
|
|||
|
|
@ -14,14 +14,17 @@ status = "draft — needs native review"
|
|||
"(no longer published)" = "(게시 중단됨)"
|
||||
"({n} warnings: some entries were skipped)" = { other = "(경고 {n}개: 일부 항목을 건너뜀)" }
|
||||
"+ Create variable" = "+ 변수 만들기"
|
||||
".fig (experimental)" = ".fig(실험적)"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR {prs}개, 커밋 {commits}개, +{added} / −{deleted}줄 (Δ {delta}), 바이너리 에셋 +{bin_added} / −{bin_deleted}, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "이 파일의 컬렉션과 그 모드 및 변수: 모드별로 값을 재정의하고 자체 변수를 추가할 수 있음"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "스프링은 멈출 때까지 재생됨. 길이는 질량, 강성, 감쇠로 결정됨"
|
||||
"About" = "정보"
|
||||
"About FigCraft" = "FigCraft 정보"
|
||||
"About this server" = "이 서버 정보"
|
||||
"Action" = "액션"
|
||||
"Actions" = "액션"
|
||||
"Actions for {name}" = "{name} 작업"
|
||||
"Actions…" = "액션…"
|
||||
"Active" = "활성"
|
||||
"Actual size (100%)" = "실제 크기(100%)"
|
||||
|
|
@ -63,6 +66,7 @@ status = "draft — needs native review"
|
|||
"Added the library \"{name}\"" = "라이브러리 \"{name}\" 추가됨"
|
||||
"Adjust" = "조정"
|
||||
"Adjustment presets" = "조정 프리셋"
|
||||
"Advanced" = "고급"
|
||||
"Advanced stroke" = "고급 선"
|
||||
"After delay" = "지연 후"
|
||||
"After delay {ms}ms" = "{ms}ms 지연 후"
|
||||
|
|
@ -86,14 +90,19 @@ status = "draft — needs native review"
|
|||
"All variables" = "모든 변수"
|
||||
"Alpha" = "알파"
|
||||
"Alt+drag" = "Alt+드래그"
|
||||
"An invite link that opens FigCraft and joins" = "FigCraft를 열고 참여하는 초대 링크"
|
||||
"Anchor" = "앵커"
|
||||
"Android" = "Android"
|
||||
"Angular" = "각도"
|
||||
"Animation" = "애니메이션"
|
||||
"Annotations" = "주석"
|
||||
"Annotations shown on the canvas" = "캔버스에 표시되는 주석"
|
||||
"Another server, or one that needs its key. The usual server is set in Preferences." = "다른 서버 또는 키가 필요한 서버. 평소 쓰는 서버는 환경설정에서 지정."
|
||||
"Any status" = "모든 상태"
|
||||
"Anyone on your network with this code can edit the file, while this app stays open." = "이 앱이 열려 있는 동안, 같은 네트워크에서 이 코드를 가진 사람은 누구나 파일을 편집할 수 있음."
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "편집 링크가 있으면 파일을 변경할 수 있고, 보기 링크로는 보기만 가능. 링크에는 비밀 정보가 담겨 있으므로 비밀번호처럼 공유하세요. 링크 추가와 취소는 `figcraft-server links`로 가능."
|
||||
"Anyone with this code can edit the file." = "이 코드를 가진 사람은 누구나 파일을 편집할 수 있음."
|
||||
"Anyone with this code can look at the file, not change it." = "이 코드를 가진 사람은 누구나 파일을 볼 수 있지만 변경할 수는 없음."
|
||||
"Appearance" = "모양"
|
||||
"Apply a component property" = "컴포넌트 속성 적용"
|
||||
"Apply variable" = "변수 적용"
|
||||
|
|
@ -108,6 +117,7 @@ status = "draft — needs native review"
|
|||
"Arrange" = "배치"
|
||||
"Arrow" = "화살표"
|
||||
"As typed" = "입력한 대로"
|
||||
"Asking the server…" = "서버에 확인하는 중…"
|
||||
"Assets" = "에셋"
|
||||
"Auto" = "자동"
|
||||
"Auto (space around)" = "자동(주변 간격)"
|
||||
|
|
@ -120,6 +130,7 @@ status = "draft — needs native review"
|
|||
"Auto layout" = "자동 레이아웃"
|
||||
"Auto rows" = "자동 행"
|
||||
"Auto width" = "자동 너비"
|
||||
"Back to the FigCraft server" = "FigCraft 서버로 되돌리기"
|
||||
"Back to the font's value" = "글꼴 값으로 되돌리기"
|
||||
"Back" = "뒤로"
|
||||
"Back to Ready for dev" = "개발 준비 완료로 되돌리기"
|
||||
|
|
@ -175,6 +186,10 @@ status = "draft — needs native review"
|
|||
"C or Shift+C" = "C 또는 Shift+C"
|
||||
"C or ⇧C" = "C 또는 ⇧C"
|
||||
"CMYK colour" = "CMYK 색상"
|
||||
"Can view" = "보기 가능"
|
||||
"Checking the server…" = "서버 확인 중…"
|
||||
"Clear from this list" = "이 목록에서 지우기"
|
||||
"Code or invite link" = "코드 또는 초대 링크"
|
||||
"Color profile: {name}" = "색상 프로필: {name}"
|
||||
"CSS custom properties…" = "CSS 사용자 정의 속성…"
|
||||
"Can edit" = "편집 가능"
|
||||
|
|
@ -281,11 +296,13 @@ status = "draft — needs native review"
|
|||
"Copy as CSS" = "CSS로 복사"
|
||||
"Copy as PNG" = "PNG로 복사"
|
||||
"Copy as SVG" = "SVG로 복사"
|
||||
"Copy code" = "코드 복사"
|
||||
"Copy link" = "링크 복사"
|
||||
"Copy the {label}" = "{label} 복사"
|
||||
"Copy {value}" = "{value} 복사"
|
||||
"Corner radius" = "모서리 반경"
|
||||
"Corner smoothing" = "모서리 스무딩"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "{server}에 연결할 수 없음: 이 컴퓨터가 기억하는 내용을 표시함."
|
||||
"Count" = "개수"
|
||||
"Create" = "만들기"
|
||||
"Create a variable collection first" = "먼저 변수 컬렉션을 만드세요"
|
||||
|
|
@ -402,6 +419,7 @@ status = "draft — needs native review"
|
|||
"Empty" = "비어 있음"
|
||||
"End" = "끝"
|
||||
"End point" = "끝점"
|
||||
"Ends the session; everyone keeps their copy" = "세션을 종료함. 각자의 사본은 그대로 유지됨"
|
||||
"Enter" = "Enter"
|
||||
"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "figcraft-server 주소를 입력하고 찾아보기를 눌러 제공되는 플러그인을 확인하세요."
|
||||
"Enter to save · Esc to cancel" = "Enter로 저장 · Esc로 취소"
|
||||
|
|
@ -427,6 +445,7 @@ status = "draft — needs native review"
|
|||
"Export {n} layers" = { other = "레이어 {n}개 내보내기" }
|
||||
"Exported {file}" = "{file} 내보냄"
|
||||
"Exported {name}" = "{name} 내보냄"
|
||||
"Exported {name}; {n} kinds of content were left out" = { other = "{name} 내보냄. 콘텐츠 {n}종은 제외됨" }
|
||||
"Exported {n} files" = { other = "파일 {n}개 내보냄" }
|
||||
"Export…" = "내보내기…"
|
||||
"Expose properties to instances" = "인스턴스에 속성 노출"
|
||||
|
|
@ -441,6 +460,9 @@ status = "draft — needs native review"
|
|||
"Fetching from the server…" = "서버에서 가져오는 중…"
|
||||
"File" = "파일"
|
||||
"File name" = "파일 이름"
|
||||
"File published" = "파일 게시됨"
|
||||
"Files aren't end-to-end encrypted: the server's operator can read them." = "파일은 종단 간 암호화되지 않음: 서버 운영자가 읽을 수 있음."
|
||||
"Files nobody opens for {n} days are deleted." = { other = "{n}일 동안 아무도 열지 않은 파일은 삭제됨." }
|
||||
"Fill" = "채우기"
|
||||
"Fill (fr)" = "채우기(fr)"
|
||||
"Fill container" = "컨테이너에 맞춤"
|
||||
|
|
@ -483,6 +505,7 @@ status = "draft — needs native review"
|
|||
"Font not installed" = "글꼴이 설치되지 않음"
|
||||
"Font size" = "글꼴 크기"
|
||||
"Font style" = "글꼴 스타일"
|
||||
"Forget on this computer" = "이 컴퓨터에서 기록 지우기"
|
||||
"Fractions" = "분수"
|
||||
"Frame" = "프레임"
|
||||
"Frame selection" = "선택 항목을 프레임으로 감싸기"
|
||||
|
|
@ -496,6 +519,7 @@ status = "draft — needs native review"
|
|||
"Gap between columns" = "열 사이 간격"
|
||||
"Gap between items" = "항목 사이 간격"
|
||||
"Gap between rows" = "행 사이 간격"
|
||||
"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "공개 서버의 주소나 키가 포함된 서버 주소를 입력하세요(`figcraft-server key`로 확인)."
|
||||
"Glass" = "유리"
|
||||
"Go to main component" = "메인 컴포넌트로 이동"
|
||||
"Go to {name}" = "{name}(으)로 이동"
|
||||
|
|
@ -519,6 +543,7 @@ status = "draft — needs native review"
|
|||
"Help" = "도움말"
|
||||
"Help and resources" = "도움말 및 리소스"
|
||||
"Help page" = "도움말 페이지"
|
||||
"Here: {names}" = "참여 중: {names}"
|
||||
"Hidden" = "숨김"
|
||||
"Hide contents" = "콘텐츠 숨기기"
|
||||
"Hide from publishing" = "게시에서 숨기기"
|
||||
|
|
@ -538,6 +563,7 @@ status = "draft — needs native review"
|
|||
"Horizontal hex" = "가로 육각형"
|
||||
"Horizontal padding" = "가로 패딩"
|
||||
"Horizontal spacing" = "가로 간격"
|
||||
"Hosted by {operator}" = "운영: {operator}"
|
||||
"Hover a layer to see its properties" = "레이어에 마우스를 올려 속성 보기"
|
||||
"Hover other layers to measure the distance" = "다른 레이어에 마우스를 올려 거리 측정"
|
||||
"Hue" = "색조"
|
||||
|
|
@ -588,8 +614,11 @@ status = "draft — needs native review"
|
|||
"Invite link copied" = "초대 링크 복사됨"
|
||||
"It asks permission to:" = "다음 권한을 요청함:"
|
||||
"Join" = "참여"
|
||||
"Join code copied" = "참여 코드 복사됨"
|
||||
"Join code or invite link" = "참여 코드 또는 초대 링크"
|
||||
"Join live session" = "라이브 세션 참여"
|
||||
"Join us on Discord" = "Discord에서 함께하기"
|
||||
"Join with a code" = "코드로 참여"
|
||||
"Joined" = "참여함"
|
||||
"Joined {file}" = "{file}에 참여함"
|
||||
"Joining…" = "참여하는 중…"
|
||||
|
|
@ -652,6 +681,7 @@ status = "draft — needs native review"
|
|||
"Live session" = "라이브 세션"
|
||||
"Live · {people}" = "라이브 · {people}"
|
||||
"Local components" = "로컬 컴포넌트"
|
||||
"Local network" = "로컬 네트워크"
|
||||
"Local styles" = "로컬 스타일"
|
||||
"Local variables" = "로컬 변수"
|
||||
"Lock/Unlock" = "잠금/잠금 해제"
|
||||
|
|
@ -741,6 +771,7 @@ status = "draft — needs native review"
|
|||
"No conflicts." = "충돌 없음."
|
||||
"No contributor data was built into this copy." = "이 사본에는 기여자 데이터가 포함되지 않음."
|
||||
"No fonts match" = "일치하는 글꼴 없음"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "참여 코드 없음(서버가 참여 코드 이전 버전이거나 이 주소를 코드에 담을 수 없음): 대신 링크를 공유하세요."
|
||||
"No layer changes to show." = "표시할 레이어 변경 사항 없음."
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "아직 라이브러리가 없음. 라이브러리로 게시된 .figcraft 파일을 추가하거나 팀 라이브러리 링크를 붙여 넣으세요."
|
||||
"No list" = "목록 없음"
|
||||
|
|
@ -779,9 +810,11 @@ status = "draft — needs native review"
|
|||
"Not in this font" = "이 글꼴에 없음"
|
||||
"Not properties, kept as written: {names}" = "속성이 아니므로 작성된 대로 유지: {names}"
|
||||
"Not read yet" = "아직 읽지 않음"
|
||||
"Not shared" = "공유 안 함"
|
||||
"Note {n}" = "메모 {n}"
|
||||
"Nothing changed since this was marked ready for dev." = "개발 준비 완료로 표시된 뒤 변경된 내용 없음."
|
||||
"Nothing here yet. Set a frame's status to list it." = "아직 아무것도 없음. 프레임의 상태를 설정하면 목록에 표시됨."
|
||||
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "아직 온라인에 올린 파일이 없음. 파일에서 공유를 사용해 온라인에 올리면 여기에 표시됨."
|
||||
"Nothing to export" = "내보낼 항목 없음"
|
||||
"Now" = "현재"
|
||||
"Nudge (Shift: big nudge)" = "미세 이동(Shift: 크게 이동)"
|
||||
|
|
@ -798,6 +831,9 @@ status = "draft — needs native review"
|
|||
"On click" = "클릭 시"
|
||||
"On drag" = "드래그 시"
|
||||
"On this page" = "이 페이지"
|
||||
"Online" = "온라인"
|
||||
"Online files" = "온라인 파일"
|
||||
"Online sharing is off: files are shared on the local network only." = "온라인 공유가 꺼져 있음: 파일은 로컬 네트워크에서만 공유됨."
|
||||
"Only the first changed layers are listed." = "변경된 레이어는 처음 일부만 표시됨."
|
||||
"Only the first {n} changed layers are listed." = "변경된 레이어는 처음 {n}개만 표시됨."
|
||||
"Only your threads" = "내 스레드만"
|
||||
|
|
@ -821,6 +857,9 @@ status = "draft — needs native review"
|
|||
"Open|comments" = "미해결"
|
||||
"Options" = "옵션"
|
||||
"Orange" = "주황"
|
||||
"Other apps may not open every .fig file exported here." = "여기서 내보낸 .fig 파일을 다른 앱에서 모두 열 수 있는 것은 아님."
|
||||
"Others lose access and keep their copy; the file stays on the server for you" = "다른 사람들은 접근 권한을 잃고 각자의 사본을 유지함. 파일은 나를 위해 서버에 남음"
|
||||
"Others type this under Share ▸ Join with a code" = "다른 사람들이 공유 ▸ 코드로 참여에서 이 코드를 입력"
|
||||
"Outline stroke" = "선 윤곽선 만들기"
|
||||
"Outline text" = "텍스트 윤곽선 만들기"
|
||||
"Outlines" = "윤곽선"
|
||||
|
|
@ -830,6 +869,8 @@ status = "draft — needs native review"
|
|||
"Overlay position" = "오버레이 위치"
|
||||
"Overridden here (the collection it extends has another value)" = "여기서 재정의됨(확장하는 컬렉션에는 다른 값이 있음)"
|
||||
"Overrides" = "재정의"
|
||||
"Owner token" = "소유자 토큰"
|
||||
"Owner token copied" = "소유자 토큰 복사됨"
|
||||
"PDF print options" = "PDF 인쇄 옵션"
|
||||
"PDF print settings" = "PDF 인쇄 설정"
|
||||
"Padding" = "패딩"
|
||||
|
|
@ -856,6 +897,8 @@ status = "draft — needs native review"
|
|||
"Pen" = "펜"
|
||||
"Pencil" = "연필"
|
||||
"People in this file" = "이 파일의 사용자"
|
||||
"People join from anywhere with a code. The file is kept on {server} while you share it." = "코드만 있으면 어디서나 참여할 수 있음. 공유하는 동안 파일은 {server}에 보관됨."
|
||||
"People on your network join with a code. Nothing goes through a server." = "같은 네트워크의 사람들이 코드로 참여함. 서버를 거치지 않음."
|
||||
"Phone" = "휴대폰"
|
||||
"Pick color" = "색상 추출"
|
||||
"Pick color (I)" = "색상 추출(I)"
|
||||
|
|
@ -876,6 +919,7 @@ status = "draft — needs native review"
|
|||
"Points" = "점"
|
||||
"Polygon" = "다각형"
|
||||
"Polygons" = "다각형"
|
||||
"Port" = "포트"
|
||||
"Position" = "위치"
|
||||
"Post" = "게시"
|
||||
"Preferences" = "환경설정"
|
||||
|
|
@ -904,12 +948,14 @@ status = "draft — needs native review"
|
|||
"Prototype settings" = "프로토타입 설정"
|
||||
"Publish" = "게시"
|
||||
"Publish changes" = "변경 사항 게시"
|
||||
"Publish to another server…" = "다른 서버에 게시…"
|
||||
"Publish to server" = "서버에 게시"
|
||||
"Published version {v}" = "버전 {v} 게시됨"
|
||||
"Published version {v} to the server" = "버전 {v}을(를) 서버에 게시함"
|
||||
"Published version {v}: save the file so other files can use it" = "버전 {v} 게시됨: 다른 파일에서 사용할 수 있도록 파일을 저장하세요"
|
||||
"Published · {people}" = "게시됨 · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "서버 게시에는 데스크톱 앱이 필요함. 브라우저에서는 서버의 파일에 참여할 수 있음"
|
||||
"Publishing with the server's key." = "서버 키로 게시함."
|
||||
"Publishing…" = "게시하는 중…"
|
||||
"Push" = "밀어내기"
|
||||
"Push changes to main component" = "변경 사항을 메인 컴포넌트에 푸시"
|
||||
|
|
@ -937,6 +983,8 @@ status = "draft — needs native review"
|
|||
"Remove auto layout" = "자동 레이아웃 제거"
|
||||
"Remove dev resource" = "개발 리소스 제거"
|
||||
"Remove export setting" = "내보내기 설정 제거"
|
||||
"Remove from server" = "서버에서 제거"
|
||||
"Remove from server…" = "서버에서 제거…"
|
||||
"Remove guide" = "안내선 제거"
|
||||
"Remove interaction" = "인터랙션 제거"
|
||||
"Remove library from this file" = "이 파일에서 라이브러리 제거"
|
||||
|
|
@ -950,7 +998,10 @@ status = "draft — needs native review"
|
|||
"Remove text from path" = "패스에서 텍스트 제거"
|
||||
"Remove the else and its actions" = "\"그 외\"와 해당 액션 제거"
|
||||
"Remove this condition and its actions" = "이 조건과 해당 액션 제거"
|
||||
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "이 파일을 서버에서 제거할까요? 서버에서 다시 공유하거나 열 수 없게 됨. 내 사본은 여기 열린 채로 유지됨."
|
||||
"Remove {name} from {server}?" = "{name}을(를) {server}에서 제거할까요?"
|
||||
"Removed" = "제거됨"
|
||||
"Removed from the server" = "서버에서 제거됨"
|
||||
"Removed since it was marked" = "표시된 뒤 제거됨"
|
||||
"Rename" = "이름 바꾸기"
|
||||
"Rename duplicated layers" = "복제한 레이어 이름 바꾸기"
|
||||
|
|
@ -958,6 +1009,8 @@ status = "draft — needs native review"
|
|||
"Rename group" = "그룹 이름 바꾸기"
|
||||
"Rename layers" = "레이어 이름 바꾸기"
|
||||
"Rename mode" = "모드 이름 바꾸기"
|
||||
"Rename on the server" = "서버에서 이름 바꾸기"
|
||||
"Rename on the server…" = "서버에서 이름 바꾸기…"
|
||||
"Rename page" = "페이지 이름 바꾸기"
|
||||
"Rename to" = "새 이름"
|
||||
"Replace all" = "모두 바꾸기"
|
||||
|
|
@ -965,6 +1018,8 @@ status = "draft — needs native review"
|
|||
"Replace with" = "바꿀 내용"
|
||||
"Replace with a new interaction" = "새 인터랙션으로 바꾸기"
|
||||
"Reply" = "답글"
|
||||
"Report" = "신고"
|
||||
"Report this file…" = "이 파일 신고…"
|
||||
"Reset" = "재설정"
|
||||
"Reset adjustments" = "조정 재설정"
|
||||
"Reset all changes" = "모든 변경 사항 재설정"
|
||||
|
|
@ -1054,9 +1109,11 @@ status = "draft — needs native review"
|
|||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "선택 상자, {w} × {h}, 위치 {x}, {y}: 화살표로 이동, Shift+화살표 또는 {cmd}+화살표로 크기 조정, Enter로 선택, Escape로 취소"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "선택 상자: 화살표로 이동, Shift+화살표 또는 {cmd}+화살표로 크기 조정, Enter로 선택, Escape로 취소"
|
||||
"Send backward" = "뒤로 보내기"
|
||||
"Send report" = "신고 보내기"
|
||||
"Send to back" = "맨 뒤로 보내기"
|
||||
"Server address" = "서버 주소"
|
||||
"Server address (ws://host:47900/)" = "서버 주소(ws://host:47900/)"
|
||||
"Server for online sharing" = "온라인 공유용 서버"
|
||||
"Set as default" = "기본값으로 설정"
|
||||
"Set variable" = "변수 설정"
|
||||
"Set variable mode" = "변수 모드 설정"
|
||||
|
|
@ -1067,7 +1124,15 @@ status = "draft — needs native review"
|
|||
"Shape builder" = "도형 빌더"
|
||||
"Shape tools" = "도형 도구"
|
||||
"Share" = "공유"
|
||||
"Share this file" = "이 파일 공유"
|
||||
"Share: live session, export" = "공유: 라이브 세션, 내보내기"
|
||||
"Shared" = "공유 중"
|
||||
"Shared: copy its code from the file's menu" = "공유됨: 파일 메뉴에서 코드를 복사하세요"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "파일을 공유하려면 데스크톱 앱이 필요함. 여기서는 참여만 가능."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "로컬 네트워크에서 공유 중(온라인 공유가 꺼져 있음: 환경설정에 서버가 지정되지 않음)."
|
||||
"Sharing on your network · {people}" = "내 네트워크에서 공유 중 · {people}"
|
||||
"Sharing online · {people}" = "온라인으로 공유 중 · {people}"
|
||||
"Sharing stopped: the file stays on the server, open only to you" = "공유 중지됨: 파일은 서버에 남으며 나만 열 수 있음"
|
||||
"Show" = "표시"
|
||||
"Show as grid" = "그리드로 보기"
|
||||
"Show as list" = "목록으로 보기"
|
||||
|
|
@ -1114,6 +1179,7 @@ status = "draft — needs native review"
|
|||
"Space evenly" = "균등 간격"
|
||||
"Space+drag" = "Space+드래그"
|
||||
"Spacing" = "간격"
|
||||
"Spam, stolen work, abuse…" = "스팸, 도용, 악용…"
|
||||
"Splay" = "펼침"
|
||||
"Spotlight me" = "나를 스포트라이트"
|
||||
"Spread" = "확산"
|
||||
|
|
@ -1127,11 +1193,14 @@ status = "draft — needs native review"
|
|||
"Start from a blank canvas" = "빈 캔버스로 시작"
|
||||
"Start on path" = "패스 시작 위치"
|
||||
"Start point" = "시작점"
|
||||
"Start sharing" = "공유 시작"
|
||||
"Starting to share…" = "공유를 시작하는 중…"
|
||||
"Status" = "상태"
|
||||
"Sticky" = "고정"
|
||||
"Stop" = "중지"
|
||||
"Stop following {name}" = "{name} 따라가기 중지"
|
||||
"Stop session" = "세션 중지"
|
||||
"Stop sharing" = "공유 중지"
|
||||
"Stop spotlighting" = "스포트라이트 중지"
|
||||
"Stop {n} {hex}" = "정지점 {n} {hex}"
|
||||
"Stretch" = "늘이기"
|
||||
|
|
@ -1170,8 +1239,10 @@ status = "draft — needs native review"
|
|||
"Take the values from the first selected layer" = "처음 선택한 레이어의 값 사용"
|
||||
"Taper" = "가늘게"
|
||||
"Taper both ends" = "양 끝 가늘게"
|
||||
"Tell the server's operator what is wrong with it" = "무엇이 문제인지 서버 운영자에게 알리기"
|
||||
"Temperature" = "색온도"
|
||||
"Template" = "템플릿"
|
||||
"Terms" = "약관"
|
||||
"Text" = "텍스트"
|
||||
"Text align" = "텍스트 정렬"
|
||||
"Text content" = "텍스트 내용"
|
||||
|
|
@ -1180,20 +1251,31 @@ status = "draft — needs native review"
|
|||
"Texts" = "텍스트"
|
||||
"Texture" = "텍스처"
|
||||
"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "캔버스는 sRGB로 색상을 미리 보여 줌. 내보낸 PNG, JPG, WebP 파일에는 Display P3 태그가 붙음."
|
||||
"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "파일이 서버에서 삭제됨: 누구도 서버에서 다시 열거나 참여할 수 없으며 되돌릴 수 없음. 열려 있는 사본은 각자에게 남음."
|
||||
"The file now holds another library" = "이 파일은 이제 다른 라이브러리를 담고 있음"
|
||||
"The file now holds another library (\"{name}\")" = "이 파일은 이제 다른 라이브러리(\"{name}\")를 담고 있음"
|
||||
"The files you shared or published online from this computer. They stay on their server until you remove them." = "이 컴퓨터에서 온라인으로 공유하거나 게시한 파일. 제거할 때까지 서버에 남음."
|
||||
"The link carries the session's secret: share it only with people you invite." = "링크에는 세션의 비밀 정보가 담겨 있음: 초대할 사람과만 공유하세요."
|
||||
"The name in your online files and on the server. The document inside keeps its own name." = "온라인 파일 목록과 서버에 표시되는 이름. 안에 있는 문서의 이름은 그대로 유지됨."
|
||||
"The selection uses several text styles: click to apply one" = "선택 항목에 여러 텍스트 스타일이 사용됨: 클릭해 하나를 적용"
|
||||
"The server deletes this file if nobody opens it before {date}." = "{date} 전에 아무도 열지 않으면 서버가 이 파일을 삭제함."
|
||||
"The whole file" = "파일 전체"
|
||||
"Theme" = "테마"
|
||||
"There's no destination for that action" = "해당 액션의 대상이 없음"
|
||||
"Thickness" = "두께"
|
||||
"This branch can't be reviewed: {e}" = "이 브랜치를 검토할 수 없음: {e}"
|
||||
"This branch was merged or archived." = "이 브랜치는 병합 또는 보관되었음."
|
||||
"This computer only" = "이 컴퓨터만"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "이 컴퓨터가 소유자 토큰을 기억하므로 홈 화면의 온라인 목록에서 파일을 관리할 수 있음. 다른 곳에서 관리하려면 복사해 두세요: 이 파일의 비밀번호와 같으며 다시 표시되지 않음."
|
||||
"This condition is edited as an expression" = "이 조건은 수식으로 편집됨"
|
||||
"This file" = "이 파일"
|
||||
"This file has unsaved changes" = "이 파일에 저장되지 않은 변경 사항이 있음"
|
||||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "이 파일은 {server}에 있으며 나만 열 수 있음. 공유하면 다른 사람들이 새 코드로 참여할 수 있음."
|
||||
"This frame is new on the branch." = "이 프레임은 브랜치에서 새로 만들어졌음."
|
||||
"This frame isn't on the branch." = "이 프레임은 브랜치에 없음."
|
||||
"This server is open to the public: no key needed." = "이 서버는 누구에게나 열려 있음: 키가 필요 없음."
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "이 서버는 키가 필요함: 주소에 ?key=…를 추가하세요(서버 컴퓨터에서 `figcraft-server key`로 확인)."
|
||||
"This server takes files only with its key: see Advanced." = "이 서버는 키가 있어야만 파일을 받음: 고급을 참고하세요."
|
||||
"Tidy up" = "정돈"
|
||||
"Tile" = "타일"
|
||||
"Tile type" = "타일 유형"
|
||||
|
|
@ -1215,10 +1297,12 @@ status = "draft — needs native review"
|
|||
"True" = "참"
|
||||
"Truncate text" = "텍스트 자르기"
|
||||
"Truncate with an ellipsis" = "줄임표로 자르기"
|
||||
"Try again" = "다시 시도"
|
||||
"Try another search or filter." = "다른 검색어나 필터를 사용해 보세요."
|
||||
"Two digits" = "두 자리"
|
||||
"Type" = "유형"
|
||||
"Type settings" = "문자 설정"
|
||||
"Type the join code you were given, or paste an invite link." = "받은 참여 코드를 입력하거나 초대 링크를 붙여 넣으세요."
|
||||
"Typography" = "타이포그래피"
|
||||
"Underline" = "밑줄"
|
||||
"Undo takes back only your own changes; what others changed since stays." = "실행 취소는 내 변경 사항만 되돌림. 그 뒤 다른 사람이 변경한 내용은 유지됨."
|
||||
|
|
@ -1290,11 +1374,13 @@ status = "draft — needs native review"
|
|||
"Wedge" = "쐐기"
|
||||
"Weight" = "굵기"
|
||||
"What changed since it was marked ready for dev" = "개발 준비 완료로 표시된 뒤 변경된 내용"
|
||||
"What is wrong with this file? The server's operator gets your report." = "이 파일에 어떤 문제가 있나요? 신고는 서버 운영자에게 전달됨."
|
||||
"What it's for" = "용도"
|
||||
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "무엇에서 바뀌었는지 알 수 없음: FigCraft가 개발 준비 완료 프레임의 사본을 보관하기 전에 변경됨. 변경을 해결(또는 다시 준비 완료로 표시)하면 이후부터 비교함."
|
||||
"While hovering" = "마우스를 올린 동안"
|
||||
"While pressing" = "누르는 동안"
|
||||
"White" = "흰색"
|
||||
"Who runs it, its terms and how long it keeps files" = "운영자, 약관, 파일 보관 기간"
|
||||
"Why it changed" = "변경 이유"
|
||||
"Widgets" = "위젯"
|
||||
"Width" = "너비"
|
||||
|
|
@ -1306,8 +1392,10 @@ status = "draft — needs native review"
|
|||
"Yellow" = "노랑"
|
||||
"Yesterday" = "어제"
|
||||
"You" = "나"
|
||||
"You'll get the shared file as a new tab and edit it together." = "공유된 파일이 새 탭으로 열리고 함께 편집하게 됨."
|
||||
"You're spotlighting: everyone follows your view" = "스포트라이트 중: 모두가 내 보기를 따라감"
|
||||
"Your copy of the file stays open" = "내 파일 사본은 열린 상태로 유지됨"
|
||||
"Your copy stays open; the file stays on the server" = "내 사본은 열린 채로 유지됨. 파일은 서버에 남음"
|
||||
"Your name" = "내 이름"
|
||||
"Your name in comments" = "댓글에 표시할 이름"
|
||||
"Your system asks for reduced motion: animations stay off." = "시스템에서 동작 줄이기를 요청하므로 애니메이션이 꺼져 있음."
|
||||
|
|
@ -1358,14 +1446,20 @@ status = "draft — needs native review"
|
|||
"{name} (you)" = "{name}(나)"
|
||||
"{name} ({layers} layers)" = "{name}(레이어 {layers}개)"
|
||||
"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name}({tag}): {min} – {max}, 기본값 {default}. 슬라이더를 두 번 클릭하면 스타일 값으로 돌아감. 드래그 중 Escape를 누르면 취소됨."
|
||||
"{name} cleared from the list" = "{name}을(를) 목록에서 지움"
|
||||
"{name} from {path}" = "{path}의 {name}"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name}의 기록을 이 컴퓨터에서 지움. 파일은 {server}에 남음"
|
||||
"{name} is on the server." = "{name}이(가) 서버에 있음."
|
||||
"{name} is spotlighting" = "{name} 님이 스포트라이트 중"
|
||||
"{name} was removed from the server" = "{name}을(를) 서버에서 제거함"
|
||||
"{name} · deleted on main" = "{name} · 메인에서 삭제됨"
|
||||
"{name} · deleted on the branch" = "{name} · 브랜치에서 삭제됨"
|
||||
"{name} · not published" = "{name} · 게시되지 않음"
|
||||
"{name} · on a server" = "{name} · 서버에 있음"
|
||||
"{name} · published version {v}" = "{name} · 게시된 버전 {v}"
|
||||
"{name} · version {v} on the server" = "{name} · 서버의 버전 {v}"
|
||||
"{name}, in a live session" = "{name}, 라이브 세션 중"
|
||||
"{name}, online, open only to you" = "{name}, 온라인, 나만 열 수 있음"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, 선택됨"
|
||||
"{n} added" = { other = "{n}개 추가됨" }
|
||||
"{n} autosave versions" = { other = "자동 저장 버전 {n}개" }
|
||||
|
|
@ -1403,6 +1497,9 @@ status = "draft — needs native review"
|
|||
"{page}, nothing selected" = "{page}, 선택된 항목 없음"
|
||||
"{profile} (current)" = "{profile}(현재)"
|
||||
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property}는 그 값이 되고, {property ? \"a\" : \"b\"}는 문자열을 고름. 그 밖의 {…}는 작성된 대로 유지됨."
|
||||
"{server} can't say what became of your files (it is older than this app)." = "{server}에서 파일 상태를 알려 줄 수 없음(이 앱보다 오래된 서버)."
|
||||
"{server} · No longer on the server" = "{server} · 더 이상 서버에 없음"
|
||||
"{server} · {shared} · Opened {date}" = "{server} · {shared} · {date}에 열림"
|
||||
"{tool} hold {key}" = "{tool} {key} 길게 누르기"
|
||||
"{value} isn't a colour" = "{value}은(는) 색상이 아님"
|
||||
"{when} · edited" = "{when} · 편집됨"
|
||||
|
|
@ -1461,13 +1558,20 @@ status = "draft — needs native review"
|
|||
"collab.cursor" = "커서 위치 표시"
|
||||
"collab.cursorChat" = "커서 채팅"
|
||||
"collab.diff" = "동기화된 변경 사항 표시"
|
||||
"collab.files" = "내 온라인 파일"
|
||||
"collab.follow" = "따라가기"
|
||||
"collab.forgetFile" = "온라인 파일 기록 지우기"
|
||||
"collab.host" = "라이브 세션 시작"
|
||||
"collab.join" = "라이브 세션 참여"
|
||||
"collab.leave" = "라이브 세션 나가기"
|
||||
"collab.manageFile" = "게시한 파일 관리"
|
||||
"collab.openFile" = "온라인 파일 열기"
|
||||
"collab.peers" = "이 파일의 사용자"
|
||||
"collab.publish" = "서버에 게시"
|
||||
"collab.registerLinks" = "초대 링크를 FigCraft에서 열기"
|
||||
"collab.report" = "서버 운영자에게 파일 신고"
|
||||
"collab.serverInfo" = "서버 정보"
|
||||
"collab.share" = "참여 코드로 공유"
|
||||
"collab.spotlight" = "나를 스포트라이트"
|
||||
"collab.stop" = "라이브 세션 중지"
|
||||
"command.batch" = "여러 명령 실행"
|
||||
|
|
@ -1686,6 +1790,7 @@ status = "draft — needs native review"
|
|||
"pref.language" = "인터페이스 언어"
|
||||
"pref.nudge" = "미세 이동 거리"
|
||||
"pref.numberKeysOpacity" = "숫자 키로 불투명도 변경"
|
||||
"pref.publishServer" = "온라인 공유용 서버"
|
||||
"pref.reduceMotion" = "동작 줄이기"
|
||||
"pref.renameDuplicates" = "복제한 레이어 이름 바꾸기"
|
||||
"pref.showDimensions" = "개체에 치수 표시"
|
||||
|
|
|
|||
|
|
@ -12,14 +12,17 @@ status = "draft — needs native review"
|
|||
"(no longer published)" = "(não publicado mais)"
|
||||
"({n} warnings: some entries were skipped)" = { one = "({n} aviso: algumas entradas foram ignoradas)", other = "({n} avisos: algumas entradas foram ignoradas)" }
|
||||
"+ Create variable" = "+ Criar variável"
|
||||
".fig (experimental)" = ".fig (experimental)"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} linhas (Δ {delta}), +{bin_added} / −{bin_deleted} arquivos binários, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Uma coleção deste arquivo com estes modos e variáveis: substitua os valores por modo e adicione variáveis próprias"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Uma mola se move até se estabilizar: sua massa, rigidez e amortecimento definem a duração"
|
||||
"About" = "Sobre"
|
||||
"About FigCraft" = "Sobre o FigCraft"
|
||||
"About this server" = "Sobre este servidor"
|
||||
"Action" = "Ação"
|
||||
"Actions" = "Ações"
|
||||
"Actions for {name}" = "Ações para {name}"
|
||||
"Actions…" = "Ações…"
|
||||
"Active" = "Ativa"
|
||||
"Actual size (100%)" = "Tamanho real (100%)"
|
||||
|
|
@ -61,6 +64,7 @@ status = "draft — needs native review"
|
|||
"Added the library \"{name}\"" = "Biblioteca \"{name}\" adicionada"
|
||||
"Adjust" = "Ajustes"
|
||||
"Adjustment presets" = "Predefinições"
|
||||
"Advanced" = "Avançado"
|
||||
"Advanced stroke" = "Traço avançado"
|
||||
"After delay" = "Após atraso"
|
||||
"After delay {ms}ms" = "Após {ms} ms"
|
||||
|
|
@ -84,14 +88,19 @@ status = "draft — needs native review"
|
|||
"All variables" = "Todas as variáveis"
|
||||
"Alpha" = "Alfa"
|
||||
"Alt+drag" = "Alt+arrastar"
|
||||
"An invite link that opens FigCraft and joins" = "Um link de convite que abre o FigCraft e entra na sessão"
|
||||
"Anchor" = "Âncora"
|
||||
"Android" = "Android"
|
||||
"Angular" = "Angular"
|
||||
"Animation" = "Animação"
|
||||
"Annotations" = "Anotações"
|
||||
"Annotations shown on the canvas" = "Anotações exibidas na tela"
|
||||
"Another server, or one that needs its key. The usual server is set in Preferences." = "Outro servidor, ou um que precisa da própria chave. O servidor habitual é definido nas Preferências."
|
||||
"Any status" = "Qualquer status"
|
||||
"Anyone on your network with this code can edit the file, while this app stays open." = "Qualquer pessoa na sua rede com este código pode editar o arquivo, enquanto este aplicativo ficar aberto."
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Qualquer pessoa com o link de edição pode alterar o arquivo; o link de visualização só permite ver. Os links carregam seus segredos: compartilhe-os como senhas. `figcraft-server links` cria outros e os revoga."
|
||||
"Anyone with this code can edit the file." = "Qualquer pessoa com este código pode editar o arquivo."
|
||||
"Anyone with this code can look at the file, not change it." = "Qualquer pessoa com este código pode ver o arquivo, não alterá-lo."
|
||||
"Appearance" = "Aparência"
|
||||
"Apply a component property" = "Aplicar uma propriedade de componente"
|
||||
"Apply variable" = "Aplicar variável"
|
||||
|
|
@ -106,6 +115,7 @@ status = "draft — needs native review"
|
|||
"Arrange" = "Organizar"
|
||||
"Arrow" = "Seta"
|
||||
"As typed" = "Como digitado"
|
||||
"Asking the server…" = "Consultando o servidor…"
|
||||
"Assets" = "Recursos"
|
||||
"Auto" = "Auto"
|
||||
"Auto (space around)" = "Auto (espaço ao redor)"
|
||||
|
|
@ -118,6 +128,7 @@ status = "draft — needs native review"
|
|||
"Auto layout" = "Layout automático"
|
||||
"Auto rows" = "Linhas automáticas"
|
||||
"Auto width" = "Largura automática"
|
||||
"Back to the FigCraft server" = "Voltar ao servidor do FigCraft"
|
||||
"Back to the font's value" = "Voltar ao valor da fonte"
|
||||
"Back" = "Voltar"
|
||||
"Back to Ready for dev" = "Voltar para Pronto para dev"
|
||||
|
|
@ -173,6 +184,10 @@ status = "draft — needs native review"
|
|||
"C or Shift+C" = "C ou Shift+C"
|
||||
"C or ⇧C" = "C ou ⇧C"
|
||||
"CMYK colour" = "Cor CMYK"
|
||||
"Can view" = "Pode visualizar"
|
||||
"Checking the server…" = "Verificando o servidor…"
|
||||
"Clear from this list" = "Limpar desta lista"
|
||||
"Code or invite link" = "Código ou link de convite"
|
||||
"Color profile: {name}" = "Perfil de cor: {name}"
|
||||
"CSS custom properties…" = "Propriedades personalizadas CSS…"
|
||||
"Can edit" = "Pode editar"
|
||||
|
|
@ -279,11 +294,13 @@ status = "draft — needs native review"
|
|||
"Copy as CSS" = "Copiar como CSS"
|
||||
"Copy as PNG" = "Copiar como PNG"
|
||||
"Copy as SVG" = "Copiar como SVG"
|
||||
"Copy code" = "Copiar código"
|
||||
"Copy link" = "Copiar link"
|
||||
"Copy the {label}" = "Copiar {label}"
|
||||
"Copy {value}" = "Copiar {value}"
|
||||
"Corner radius" = "Raio do canto"
|
||||
"Corner smoothing" = "Suavização de cantos"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Não foi possível alcançar {server}: mostrando o que este computador lembra."
|
||||
"Count" = "Quantidade"
|
||||
"Create" = "Criar"
|
||||
"Create a variable collection first" = "Crie primeiro uma coleção de variáveis"
|
||||
|
|
@ -400,6 +417,7 @@ status = "draft — needs native review"
|
|||
"Empty" = "Vazio"
|
||||
"End" = "Fim"
|
||||
"End point" = "Ponto final"
|
||||
"Ends the session; everyone keeps their copy" = "Encerra a sessão; cada pessoa fica com a própria cópia"
|
||||
"Enter" = "Enter"
|
||||
"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Digite o endereço de um figcraft-server e pressione Procurar para ver os plugins que ele oferece."
|
||||
"Enter to save · Esc to cancel" = "Enter para salvar · Esc para cancelar"
|
||||
|
|
@ -425,6 +443,7 @@ status = "draft — needs native review"
|
|||
"Export {n} layers" = { one = "Exportar {n} camada", other = "Exportar {n} camadas" }
|
||||
"Exported {file}" = "Exportado: {file}"
|
||||
"Exported {name}" = "{name} exportado"
|
||||
"Exported {name}; {n} kinds of content were left out" = { one = "{name} exportado; {n} tipo de conteúdo ficou de fora", other = "{name} exportado; {n} tipos de conteúdo ficaram de fora" }
|
||||
"Exported {n} files" = { one = "{n} arquivo exportado", other = "{n} arquivos exportados" }
|
||||
"Export…" = "Exportar…"
|
||||
"Expose properties to instances" = "Expor propriedades às instâncias"
|
||||
|
|
@ -439,6 +458,9 @@ status = "draft — needs native review"
|
|||
"Fetching from the server…" = "Buscando no servidor…"
|
||||
"File" = "Arquivo"
|
||||
"File name" = "Nome do arquivo"
|
||||
"File published" = "Arquivo publicado"
|
||||
"Files aren't end-to-end encrypted: the server's operator can read them." = "Os arquivos não têm criptografia de ponta a ponta: o operador do servidor pode lê-los."
|
||||
"Files nobody opens for {n} days are deleted." = { one = "Arquivos que ninguém abre por {n} dia são excluídos.", other = "Arquivos que ninguém abre por {n} dias são excluídos." }
|
||||
"Fill" = "Preenchimento"
|
||||
"Fill (fr)" = "Preencher (fr)"
|
||||
"Fill container" = "Preencher contêiner"
|
||||
|
|
@ -481,6 +503,7 @@ status = "draft — needs native review"
|
|||
"Font not installed" = "Fonte não instalada"
|
||||
"Font size" = "Tamanho da fonte"
|
||||
"Font style" = "Estilo da fonte"
|
||||
"Forget on this computer" = "Esquecer neste computador"
|
||||
"Fractions" = "Frações"
|
||||
"Frame" = "Quadro"
|
||||
"Frame selection" = "Enquadrar seleção"
|
||||
|
|
@ -494,6 +517,7 @@ status = "draft — needs native review"
|
|||
"Gap between columns" = "Espaço entre colunas"
|
||||
"Gap between items" = "Espaço entre itens"
|
||||
"Gap between rows" = "Espaço entre linhas"
|
||||
"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Informe o endereço de um servidor aberto ao público, ou o endereço de um servidor com a chave dele (`figcraft-server key` a mostra)."
|
||||
"Glass" = "Vidro"
|
||||
"Go to main component" = "Ir para o componente principal"
|
||||
"Go to {name}" = "Ir para {name}"
|
||||
|
|
@ -517,6 +541,7 @@ status = "draft — needs native review"
|
|||
"Help" = "Ajuda"
|
||||
"Help and resources" = "Ajuda e recursos"
|
||||
"Help page" = "Página de ajuda"
|
||||
"Here: {names}" = "Aqui: {names}"
|
||||
"Hidden" = "Oculta"
|
||||
"Hide contents" = "Ocultar conteúdo"
|
||||
"Hide from publishing" = "Não publicar"
|
||||
|
|
@ -536,6 +561,7 @@ status = "draft — needs native review"
|
|||
"Horizontal hex" = "Hexágono horizontal"
|
||||
"Horizontal padding" = "Espaçamento interno horizontal"
|
||||
"Horizontal spacing" = "Espaçamento horizontal"
|
||||
"Hosted by {operator}" = "Hospedado por {operator}"
|
||||
"Hover a layer to see its properties" = "Passe o cursor sobre uma camada para ver suas propriedades"
|
||||
"Hover other layers to measure the distance" = "Passe o cursor sobre outras camadas para medir a distância"
|
||||
"Hue" = "Matiz"
|
||||
|
|
@ -586,8 +612,11 @@ status = "draft — needs native review"
|
|||
"Invite link copied" = "Link de convite copiado"
|
||||
"It asks permission to:" = "Ele pede permissão para:"
|
||||
"Join" = "Entrar"
|
||||
"Join code copied" = "Código de acesso copiado"
|
||||
"Join code or invite link" = "Código de acesso ou link de convite"
|
||||
"Join live session" = "Entrar na sessão ao vivo"
|
||||
"Join us on Discord" = "Junte-se a nós no Discord"
|
||||
"Join with a code" = "Entrar com um código"
|
||||
"Joined" = "Conectado"
|
||||
"Joined {file}" = "Conectado a {file}"
|
||||
"Joining…" = "Entrando…"
|
||||
|
|
@ -650,6 +679,7 @@ status = "draft — needs native review"
|
|||
"Live session" = "Sessão ao vivo"
|
||||
"Live · {people}" = "Ao vivo · {people}"
|
||||
"Local components" = "Componentes locais"
|
||||
"Local network" = "Rede local"
|
||||
"Local styles" = "Estilos locais"
|
||||
"Local variables" = "Variáveis locais"
|
||||
"Lock/Unlock" = "Bloquear/desbloquear"
|
||||
|
|
@ -739,6 +769,7 @@ status = "draft — needs native review"
|
|||
"No conflicts." = "Nenhum conflito."
|
||||
"No contributor data was built into this copy." = "Esta cópia não inclui dados de colaboradores."
|
||||
"No fonts match" = "Nenhuma fonte corresponde"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Não há código de acesso aqui (o servidor é anterior aos códigos de acesso, ou este endereço não cabe em um): compartilhe o link."
|
||||
"No layer changes to show." = "Nenhuma alteração de camada para mostrar."
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Ainda não há bibliotecas. Adicione um arquivo .figcraft publicado como biblioteca ou cole o link de uma biblioteca de equipe."
|
||||
"No list" = "Sem lista"
|
||||
|
|
@ -777,9 +808,11 @@ status = "draft — needs native review"
|
|||
"Not in this font" = "Não existe nesta fonte"
|
||||
"Not properties, kept as written: {names}" = "Não são propriedades, mantidos como escritos: {names}"
|
||||
"Not read yet" = "Ainda não lido"
|
||||
"Not shared" = "Não compartilhado"
|
||||
"Note {n}" = "Nota {n}"
|
||||
"Nothing changed since this was marked ready for dev." = "Nada mudou desde que isto foi marcado como pronto para dev."
|
||||
"Nothing here yet. Set a frame's status to list it." = "Ainda não há nada aqui. Defina o status de um quadro para listá-lo."
|
||||
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Nada online ainda. Use Compartilhar em um arquivo para colocá-lo online e ele aparecerá aqui."
|
||||
"Nothing to export" = "Nada para exportar"
|
||||
"Now" = "Agora"
|
||||
"Nudge (Shift: big nudge)" = "Deslocar (Shift: deslocamento grande)"
|
||||
|
|
@ -796,6 +829,9 @@ status = "draft — needs native review"
|
|||
"On click" = "Ao clicar"
|
||||
"On drag" = "Ao arrastar"
|
||||
"On this page" = "Nesta página"
|
||||
"Online" = "Online"
|
||||
"Online files" = "Arquivos online"
|
||||
"Online sharing is off: files are shared on the local network only." = "O compartilhamento online está desativado: os arquivos são compartilhados apenas na rede local."
|
||||
"Only the first changed layers are listed." = "Somente as primeiras camadas alteradas são listadas."
|
||||
"Only the first {n} changed layers are listed." = "Somente as primeiras {n} camadas alteradas são listadas."
|
||||
"Only your threads" = "Somente suas conversas"
|
||||
|
|
@ -819,6 +855,9 @@ status = "draft — needs native review"
|
|||
"Open|comments" = "Abertos"
|
||||
"Options" = "Opções"
|
||||
"Orange" = "Laranja"
|
||||
"Other apps may not open every .fig file exported here." = "Outros aplicativos podem não abrir todos os arquivos .fig exportados aqui."
|
||||
"Others lose access and keep their copy; the file stays on the server for you" = "Os outros perdem o acesso e ficam com a própria cópia; o arquivo continua no servidor para você"
|
||||
"Others type this under Share ▸ Join with a code" = "Os outros digitam isto em Compartilhar ▸ Entrar com um código"
|
||||
"Outline stroke" = "Contornar traço"
|
||||
"Outline text" = "Converter texto em contornos"
|
||||
"Outlines" = "Contornos"
|
||||
|
|
@ -828,6 +867,8 @@ status = "draft — needs native review"
|
|||
"Overlay position" = "Posição da sobreposição"
|
||||
"Overridden here (the collection it extends has another value)" = "Substituído aqui (a coleção estendida tem outro valor)"
|
||||
"Overrides" = "Substituições"
|
||||
"Owner token" = "Token de proprietário"
|
||||
"Owner token copied" = "Token de proprietário copiado"
|
||||
"PDF print options" = "Opções de impressão em PDF"
|
||||
"PDF print settings" = "Configurações de impressão em PDF"
|
||||
"Padding" = "Espaçamento interno"
|
||||
|
|
@ -854,6 +895,8 @@ status = "draft — needs native review"
|
|||
"Pen" = "Caneta"
|
||||
"Pencil" = "Lápis"
|
||||
"People in this file" = "Pessoas neste arquivo"
|
||||
"People join from anywhere with a code. The file is kept on {server} while you share it." = "As pessoas entram de qualquer lugar com um código. O arquivo fica em {server} enquanto você o compartilha."
|
||||
"People on your network join with a code. Nothing goes through a server." = "As pessoas da sua rede entram com um código. Nada passa por um servidor."
|
||||
"Phone" = "Celular"
|
||||
"Pick color" = "Selecionar cor"
|
||||
"Pick color (I)" = "Selecionar cor (I)"
|
||||
|
|
@ -874,6 +917,7 @@ status = "draft — needs native review"
|
|||
"Points" = "Pontos"
|
||||
"Polygon" = "Polígono"
|
||||
"Polygons" = "Polígonos"
|
||||
"Port" = "Porta"
|
||||
"Position" = "Posição"
|
||||
"Post" = "Publicar"
|
||||
"Preferences" = "Preferências"
|
||||
|
|
@ -902,12 +946,14 @@ status = "draft — needs native review"
|
|||
"Prototype settings" = "Configurações do protótipo"
|
||||
"Publish" = "Publicar"
|
||||
"Publish changes" = "Publicar alterações"
|
||||
"Publish to another server…" = "Publicar em outro servidor…"
|
||||
"Publish to server" = "Publicar no servidor"
|
||||
"Published version {v}" = "Versão {v} publicada"
|
||||
"Published version {v} to the server" = "Versão {v} publicada no servidor"
|
||||
"Published version {v}: save the file so other files can use it" = "Versão {v} publicada: salve o arquivo para que outros arquivos possam usá-la"
|
||||
"Published · {people}" = "Publicado · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Para publicar em um servidor é preciso o app para desktop; no navegador você pode entrar em arquivos de um servidor"
|
||||
"Publishing with the server's key." = "Publicando com a chave do servidor."
|
||||
"Publishing…" = "Publicando…"
|
||||
"Push" = "Empurrar"
|
||||
"Push changes to main component" = "Enviar alterações ao componente principal"
|
||||
|
|
@ -935,6 +981,8 @@ status = "draft — needs native review"
|
|||
"Remove auto layout" = "Remover layout automático"
|
||||
"Remove dev resource" = "Remover recurso de desenvolvimento"
|
||||
"Remove export setting" = "Remover configuração de exportação"
|
||||
"Remove from server" = "Remover do servidor"
|
||||
"Remove from server…" = "Remover do servidor…"
|
||||
"Remove guide" = "Remover guia"
|
||||
"Remove interaction" = "Remover interação"
|
||||
"Remove library from this file" = "Remover biblioteca deste arquivo"
|
||||
|
|
@ -948,7 +996,10 @@ status = "draft — needs native review"
|
|||
"Remove text from path" = "Remover texto do caminho"
|
||||
"Remove the else and its actions" = "Remover o \"senão\" e suas ações"
|
||||
"Remove this condition and its actions" = "Remover esta condição e suas ações"
|
||||
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Remover este arquivo do servidor? Ele não poderá mais ser compartilhado nem aberto a partir de lá. Sua cópia continua aberta aqui."
|
||||
"Remove {name} from {server}?" = "Remover {name} de {server}?"
|
||||
"Removed" = "Removido"
|
||||
"Removed from the server" = "Removido do servidor"
|
||||
"Removed since it was marked" = "Removido desde a marcação"
|
||||
"Rename" = "Renomear"
|
||||
"Rename duplicated layers" = "Renomear camadas duplicadas"
|
||||
|
|
@ -956,6 +1007,8 @@ status = "draft — needs native review"
|
|||
"Rename group" = "Renomear grupo"
|
||||
"Rename layers" = "Renomear camadas"
|
||||
"Rename mode" = "Renomear modo"
|
||||
"Rename on the server" = "Renomear no servidor"
|
||||
"Rename on the server…" = "Renomear no servidor…"
|
||||
"Rename page" = "Renomear página"
|
||||
"Rename to" = "Renomear para"
|
||||
"Replace all" = "Substituir tudo"
|
||||
|
|
@ -963,6 +1016,8 @@ status = "draft — needs native review"
|
|||
"Replace with" = "Substituir por"
|
||||
"Replace with a new interaction" = "Substituir por uma nova interação"
|
||||
"Reply" = "Responder"
|
||||
"Report" = "Denúncia"
|
||||
"Report this file…" = "Denunciar este arquivo…"
|
||||
"Reset" = "Redefinir"
|
||||
"Reset adjustments" = "Redefinir ajustes"
|
||||
"Reset all changes" = "Redefinir todas as alterações"
|
||||
|
|
@ -1052,9 +1107,11 @@ status = "draft — needs native review"
|
|||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Caixa de seleção, {w} por {h} em {x}, {y}: as setas a movem, Shift+setas ou {cmd}+setas a redimensionam, Enter seleciona, Esc cancela"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Caixa de seleção: as setas a movem, Shift+setas ou {cmd}+setas a redimensionam, Enter seleciona, Esc cancela"
|
||||
"Send backward" = "Enviar para trás"
|
||||
"Send report" = "Enviar denúncia"
|
||||
"Send to back" = "Enviar para o plano de fundo"
|
||||
"Server address" = "Endereço do servidor"
|
||||
"Server address (ws://host:47900/)" = "Endereço do servidor (ws://host:47900/)"
|
||||
"Server for online sharing" = "Servidor para compartilhamento online"
|
||||
"Set as default" = "Definir como padrão"
|
||||
"Set variable" = "Definir variável"
|
||||
"Set variable mode" = "Definir modo de variável"
|
||||
|
|
@ -1065,7 +1122,15 @@ status = "draft — needs native review"
|
|||
"Shape builder" = "Construtor de formas"
|
||||
"Shape tools" = "Ferramentas de forma"
|
||||
"Share" = "Compartilhar"
|
||||
"Share this file" = "Compartilhar este arquivo"
|
||||
"Share: live session, export" = "Compartilhar: sessão ao vivo, exportar"
|
||||
"Shared" = "Compartilhado"
|
||||
"Shared: copy its code from the file's menu" = "Compartilhado: copie o código pelo menu do arquivo"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Compartilhar um arquivo requer o aplicativo para desktop; aqui você pode entrar em um."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Compartilhando na rede local (o compartilhamento online está desativado: não há servidor definido nas Preferências)."
|
||||
"Sharing on your network · {people}" = "Compartilhando na sua rede · {people}"
|
||||
"Sharing online · {people}" = "Compartilhando online · {people}"
|
||||
"Sharing stopped: the file stays on the server, open only to you" = "Compartilhamento encerrado: o arquivo continua no servidor, aberto somente para você"
|
||||
"Show" = "Mostrar"
|
||||
"Show as grid" = "Mostrar em grade"
|
||||
"Show as list" = "Mostrar em lista"
|
||||
|
|
@ -1112,6 +1177,7 @@ status = "draft — needs native review"
|
|||
"Space evenly" = "Espaço uniforme"
|
||||
"Space+drag" = "Espaço+arrastar"
|
||||
"Spacing" = "Espaçamento"
|
||||
"Spam, stolen work, abuse…" = "Spam, trabalho roubado, abuso…"
|
||||
"Splay" = "Abertura"
|
||||
"Spotlight me" = "Colocar o foco em mim"
|
||||
"Spread" = "Expansão"
|
||||
|
|
@ -1125,11 +1191,14 @@ status = "draft — needs native review"
|
|||
"Start from a blank canvas" = "Comece com uma tela em branco"
|
||||
"Start on path" = "Início no caminho"
|
||||
"Start point" = "Ponto inicial"
|
||||
"Start sharing" = "Começar a compartilhar"
|
||||
"Starting to share…" = "Começando a compartilhar…"
|
||||
"Status" = "Status"
|
||||
"Sticky" = "Fixado"
|
||||
"Stop" = "Parar"
|
||||
"Stop following {name}" = "Parar de seguir {name}"
|
||||
"Stop session" = "Encerrar sessão"
|
||||
"Stop sharing" = "Parar de compartilhar"
|
||||
"Stop spotlighting" = "Remover o foco"
|
||||
"Stop {n} {hex}" = "Parada {n} {hex}"
|
||||
"Stretch" = "Esticar"
|
||||
|
|
@ -1168,8 +1237,10 @@ status = "draft — needs native review"
|
|||
"Take the values from the first selected layer" = "Usar os valores da primeira camada selecionada"
|
||||
"Taper" = "Afunilado"
|
||||
"Taper both ends" = "Afunilar as duas pontas"
|
||||
"Tell the server's operator what is wrong with it" = "Diga ao operador do servidor o que há de errado com ele"
|
||||
"Temperature" = "Temperatura"
|
||||
"Template" = "Modelo"
|
||||
"Terms" = "Termos"
|
||||
"Text" = "Texto"
|
||||
"Text align" = "Alinhamento do texto"
|
||||
"Text content" = "Conteúdo do texto"
|
||||
|
|
@ -1178,20 +1249,31 @@ status = "draft — needs native review"
|
|||
"Texts" = "Textos"
|
||||
"Texture" = "Textura"
|
||||
"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "A tela exibe as cores em sRGB; os arquivos PNG, JPG e WebP exportados são marcados como Display P3."
|
||||
"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "O arquivo é excluído do servidor: ninguém poderá abri-lo nem entrar nele a partir de lá de novo, e isso não pode ser desfeito. As cópias abertas ficam com quem as tem."
|
||||
"The file now holds another library" = "O arquivo agora contém outra biblioteca"
|
||||
"The file now holds another library (\"{name}\")" = "O arquivo agora contém outra biblioteca (\"{name}\")"
|
||||
"The files you shared or published online from this computer. They stay on their server until you remove them." = "Os arquivos que você compartilhou ou publicou online a partir deste computador. Eles ficam no servidor deles até você removê-los."
|
||||
"The link carries the session's secret: share it only with people you invite." = "O link carrega o segredo da sessão: compartilhe-o somente com as pessoas que você convidar."
|
||||
"The name in your online files and on the server. The document inside keeps its own name." = "O nome nos seus arquivos online e no servidor. O documento dentro dele mantém o próprio nome."
|
||||
"The selection uses several text styles: click to apply one" = "A seleção usa vários estilos de texto: clique para aplicar um"
|
||||
"The server deletes this file if nobody opens it before {date}." = "O servidor exclui este arquivo se ninguém o abrir antes de {date}."
|
||||
"The whole file" = "O arquivo inteiro"
|
||||
"Theme" = "Tema"
|
||||
"There's no destination for that action" = "Essa ação não tem destino"
|
||||
"Thickness" = "Espessura"
|
||||
"This branch can't be reviewed: {e}" = "Não é possível revisar esta branch: {e}"
|
||||
"This branch was merged or archived." = "Esta branch foi mesclada ou arquivada."
|
||||
"This computer only" = "Somente este computador"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Este computador guarda o token de proprietário, então o arquivo pode ser gerenciado pela lista Online da tela inicial. Copie-o para gerenciar o arquivo de outro lugar: ele é uma senha para este arquivo e não será mostrado de novo."
|
||||
"This condition is edited as an expression" = "Esta condição é editada como expressão"
|
||||
"This file" = "Este arquivo"
|
||||
"This file has unsaved changes" = "Este arquivo tem alterações não salvas"
|
||||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Este arquivo está em {server}, aberto somente para você. Compartilhe-o para deixar outras pessoas entrarem com um novo código."
|
||||
"This frame is new on the branch." = "Este quadro é novo na branch."
|
||||
"This frame isn't on the branch." = "Este quadro não está na branch."
|
||||
"This server is open to the public: no key needed." = "Este servidor é aberto ao público: não precisa de chave."
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Este servidor precisa da própria chave: adicione ?key=… ao endereço (`figcraft-server key` a mostra na máquina do servidor)."
|
||||
"This server takes files only with its key: see Advanced." = "Este servidor só aceita arquivos com a própria chave: veja Avançado."
|
||||
"Tidy up" = "Organizar"
|
||||
"Tile" = "Mosaico"
|
||||
"Tile type" = "Tipo de mosaico"
|
||||
|
|
@ -1213,10 +1295,12 @@ status = "draft — needs native review"
|
|||
"True" = "Verdadeiro"
|
||||
"Truncate text" = "Truncar texto"
|
||||
"Truncate with an ellipsis" = "Truncar com reticências"
|
||||
"Try again" = "Tentar novamente"
|
||||
"Try another search or filter." = "Tente outra pesquisa ou outro filtro."
|
||||
"Two digits" = "Dois dígitos"
|
||||
"Type" = "Tipo"
|
||||
"Type settings" = "Configurações de texto"
|
||||
"Type the join code you were given, or paste an invite link." = "Digite o código de acesso que você recebeu, ou cole um link de convite."
|
||||
"Typography" = "Tipografia"
|
||||
"Underline" = "Sublinhado"
|
||||
"Undo takes back only your own changes; what others changed since stays." = "Desfazer reverte somente as suas alterações; o que outras pessoas mudaram depois permanece."
|
||||
|
|
@ -1288,11 +1372,13 @@ status = "draft — needs native review"
|
|||
"Wedge" = "Cunha"
|
||||
"Weight" = "Espessura"
|
||||
"What changed since it was marked ready for dev" = "O que mudou desde que foi marcado como pronto para dev"
|
||||
"What is wrong with this file? The server's operator gets your report." = "O que há de errado com este arquivo? O operador do servidor recebe a sua denúncia."
|
||||
"What it's for" = "Para que serve"
|
||||
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Não se sabe de onde esta alteração partiu: ela ocorreu antes de o FigCraft guardar cópias dos quadros prontos para dev. Resolva a alteração (ou marque-o como pronto de novo) para comparar daqui em diante."
|
||||
"While hovering" = "Enquanto passa o cursor"
|
||||
"While pressing" = "Enquanto pressiona"
|
||||
"White" = "Branco"
|
||||
"Who runs it, its terms and how long it keeps files" = "Quem o mantém, os termos dele e por quanto tempo guarda os arquivos"
|
||||
"Why it changed" = "Por que mudou"
|
||||
"Widgets" = "Widgets"
|
||||
"Width" = "Largura"
|
||||
|
|
@ -1304,8 +1390,10 @@ status = "draft — needs native review"
|
|||
"Yellow" = "Amarelo"
|
||||
"Yesterday" = "Ontem"
|
||||
"You" = "Você"
|
||||
"You'll get the shared file as a new tab and edit it together." = "Você receberá o arquivo compartilhado em uma nova aba e o editarão juntos."
|
||||
"You're spotlighting: everyone follows your view" = "Você está em destaque: todos seguem sua visualização"
|
||||
"Your copy of the file stays open" = "Sua cópia do arquivo continua aberta"
|
||||
"Your copy stays open; the file stays on the server" = "Sua cópia continua aberta; o arquivo continua no servidor"
|
||||
"Your name" = "Seu nome"
|
||||
"Your name in comments" = "Seu nome nos comentários"
|
||||
"Your system asks for reduced motion: animations stay off." = "Seu sistema pede movimento reduzido: as animações ficam desativadas."
|
||||
|
|
@ -1356,14 +1444,20 @@ status = "draft — needs native review"
|
|||
"{name} (you)" = "{name} (você)"
|
||||
"{name} ({layers} layers)" = "{name} ({layers} camadas)"
|
||||
"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, padrão {default}. Clique duas vezes no controle deslizante para voltar ao valor do estilo; Esc durante o arraste cancela."
|
||||
"{name} cleared from the list" = "{name} foi limpo da lista"
|
||||
"{name} from {path}" = "{name} de {path}"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} foi esquecido neste computador; o arquivo continua em {server}"
|
||||
"{name} is on the server." = "{name} está no servidor."
|
||||
"{name} is spotlighting" = "{name} está em destaque"
|
||||
"{name} was removed from the server" = "{name} foi removido do servidor"
|
||||
"{name} · deleted on main" = "{name} · excluído no principal"
|
||||
"{name} · deleted on the branch" = "{name} · excluído na branch"
|
||||
"{name} · not published" = "{name} · não publicado"
|
||||
"{name} · on a server" = "{name} · em um servidor"
|
||||
"{name} · published version {v}" = "{name} · versão publicada {v}"
|
||||
"{name} · version {v} on the server" = "{name} · versão {v} no servidor"
|
||||
"{name}, in a live session" = "{name}, em uma sessão ao vivo"
|
||||
"{name}, online, open only to you" = "{name}, online, aberto somente para você"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} por {h}, selecionado"
|
||||
"{n} added" = { one = "{n} adicionada", other = "{n} adicionadas" }
|
||||
"{n} autosave versions" = { one = "{n} versão salva automaticamente", other = "{n} versões salvas automaticamente" }
|
||||
|
|
@ -1401,6 +1495,9 @@ status = "draft — needs native review"
|
|||
"{page}, nothing selected" = "{page}, nada selecionado"
|
||||
"{profile} (current)" = "{profile} (atual)"
|
||||
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} é o valor dela; {property ? \"a\" : \"b\"} escolhe uma string. Qualquer outro {…} fica como está escrito."
|
||||
"{server} can't say what became of your files (it is older than this app)." = "{server} não sabe dizer o que aconteceu com os seus arquivos (é mais antigo que este aplicativo)."
|
||||
"{server} · No longer on the server" = "{server} · Não está mais no servidor"
|
||||
"{server} · {shared} · Opened {date}" = "{server} · {shared} · Aberto em {date}"
|
||||
"{tool} hold {key}" = "{tool} segure {key}"
|
||||
"{value} isn't a colour" = "{value} não é uma cor"
|
||||
"{when} · edited" = "{when} · editado"
|
||||
|
|
@ -1459,13 +1556,20 @@ status = "draft — needs native review"
|
|||
"collab.cursor" = "Mostrar cursor em"
|
||||
"collab.cursorChat" = "Chat do cursor"
|
||||
"collab.diff" = "Mostrar alterações sincronizadas"
|
||||
"collab.files" = "Seus arquivos online"
|
||||
"collab.follow" = "Seguir"
|
||||
"collab.forgetFile" = "Esquecer arquivo online"
|
||||
"collab.host" = "Iniciar sessão ao vivo"
|
||||
"collab.join" = "Entrar em uma sessão ao vivo"
|
||||
"collab.leave" = "Sair da sessão ao vivo"
|
||||
"collab.manageFile" = "Gerenciar arquivo publicado"
|
||||
"collab.openFile" = "Abrir arquivo online"
|
||||
"collab.peers" = "Pessoas neste arquivo"
|
||||
"collab.publish" = "Publicar no servidor"
|
||||
"collab.registerLinks" = "Abrir links de convite no FigCraft"
|
||||
"collab.report" = "Denunciar arquivo ao operador do servidor"
|
||||
"collab.serverInfo" = "Informações do servidor"
|
||||
"collab.share" = "Compartilhar com um código de acesso"
|
||||
"collab.spotlight" = "Colocar o foco em mim"
|
||||
"collab.stop" = "Encerrar sessão ao vivo"
|
||||
"command.batch" = "Executar vários comandos"
|
||||
|
|
@ -1684,6 +1788,7 @@ status = "draft — needs native review"
|
|||
"pref.language" = "Idioma da interface"
|
||||
"pref.nudge" = "Valor do deslocamento"
|
||||
"pref.numberKeysOpacity" = "Usar teclas numéricas para a opacidade"
|
||||
"pref.publishServer" = "Servidor para compartilhamento online"
|
||||
"pref.reduceMotion" = "Reduzir movimento"
|
||||
"pref.renameDuplicates" = "Renomear camadas duplicadas"
|
||||
"pref.showDimensions" = "Mostrar dimensões nos objetos"
|
||||
|
|
|
|||
|
|
@ -15,14 +15,17 @@ status = "draft — needs native review"
|
|||
"(no longer published)" = "(已停止发布)"
|
||||
"({n} warnings: some entries were skipped)" = { other = "({n} 条警告:已跳过部分条目)" }
|
||||
"+ Create variable" = "+ 创建变量"
|
||||
".fig (experimental)" = ".fig(实验性)"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:{prs} 个 PR,{commits} 次提交,+{added} / −{deleted} 行(Δ {delta}),二进制资源 +{bin_added} / −{bin_deleted},{first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "本文件中带有这些模式和变量的集合:可按模式覆盖其值,并添加自己的变量"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "弹簧动画播放至静止,时长由质量、刚度和阻尼决定"
|
||||
"About" = "关于"
|
||||
"About FigCraft" = "关于 FigCraft"
|
||||
"About this server" = "关于此服务器"
|
||||
"Action" = "操作"
|
||||
"Actions" = "操作"
|
||||
"Actions for {name}" = "{name} 的操作"
|
||||
"Actions…" = "操作…"
|
||||
"Active" = "进行中"
|
||||
"Actual size (100%)" = "实际大小(100%)"
|
||||
|
|
@ -64,6 +67,7 @@ status = "draft — needs native review"
|
|||
"Added the library \"{name}\"" = "已添加库“{name}”"
|
||||
"Adjust" = "调整"
|
||||
"Adjustment presets" = "调整预设"
|
||||
"Advanced" = "高级"
|
||||
"Advanced stroke" = "高级描边"
|
||||
"After delay" = "延迟后"
|
||||
"After delay {ms}ms" = "延迟 {ms} 毫秒后"
|
||||
|
|
@ -87,14 +91,19 @@ status = "draft — needs native review"
|
|||
"All variables" = "全部变量"
|
||||
"Alpha" = "Alpha"
|
||||
"Alt+drag" = "Alt+拖动"
|
||||
"An invite link that opens FigCraft and joins" = "可打开 FigCraft 并加入的邀请链接"
|
||||
"Anchor" = "锚点"
|
||||
"Android" = "Android"
|
||||
"Angular" = "角度"
|
||||
"Animation" = "动画"
|
||||
"Annotations" = "标注"
|
||||
"Annotations shown on the canvas" = "画布上显示的标注"
|
||||
"Another server, or one that needs its key. The usual server is set in Preferences." = "其他服务器,或需要密钥的服务器。常用服务器在偏好设置中指定。"
|
||||
"Any status" = "任意状态"
|
||||
"Anyone on your network with this code can edit the file, while this app stays open." = "只要此应用保持打开,你的网络中持有此代码的任何人都可以编辑文件。"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "持有编辑链接的任何人都可以修改文件;查看链接只能查看。链接包含密钥,请像密码一样分享。`figcraft-server links` 可生成更多链接或将其撤销。"
|
||||
"Anyone with this code can edit the file." = "持有此代码的任何人都可以编辑文件。"
|
||||
"Anyone with this code can look at the file, not change it." = "持有此代码的任何人都可以查看文件,但不能修改。"
|
||||
"Appearance" = "外观"
|
||||
"Apply a component property" = "应用组件属性"
|
||||
"Apply variable" = "应用变量"
|
||||
|
|
@ -109,6 +118,7 @@ status = "draft — needs native review"
|
|||
"Arrange" = "排列"
|
||||
"Arrow" = "箭头"
|
||||
"As typed" = "原样"
|
||||
"Asking the server…" = "正在询问服务器…"
|
||||
"Assets" = "资源"
|
||||
"Auto" = "自动"
|
||||
"Auto (space around)" = "自动(环绕间距)"
|
||||
|
|
@ -121,6 +131,7 @@ status = "draft — needs native review"
|
|||
"Auto layout" = "自动布局"
|
||||
"Auto rows" = "自动行"
|
||||
"Auto width" = "自动宽度"
|
||||
"Back to the FigCraft server" = "恢复为 FigCraft 服务器"
|
||||
"Back to the font's value" = "恢复为字体的值"
|
||||
"Back" = "返回"
|
||||
"Back to Ready for dev" = "退回可供开发"
|
||||
|
|
@ -176,6 +187,10 @@ status = "draft — needs native review"
|
|||
"C or Shift+C" = "C 或 Shift+C"
|
||||
"C or ⇧C" = "C 或 ⇧C"
|
||||
"CMYK colour" = "CMYK 颜色"
|
||||
"Can view" = "可查看"
|
||||
"Checking the server…" = "正在检查服务器…"
|
||||
"Clear from this list" = "从此列表中清除"
|
||||
"Code or invite link" = "代码或邀请链接"
|
||||
"Color profile: {name}" = "颜色配置文件:{name}"
|
||||
"CSS custom properties…" = "CSS 自定义属性…"
|
||||
"Can edit" = "可编辑"
|
||||
|
|
@ -282,11 +297,13 @@ status = "draft — needs native review"
|
|||
"Copy as CSS" = "复制为 CSS"
|
||||
"Copy as PNG" = "复制为 PNG"
|
||||
"Copy as SVG" = "复制为 SVG"
|
||||
"Copy code" = "复制代码"
|
||||
"Copy link" = "复制链接"
|
||||
"Copy the {label}" = "复制{label}"
|
||||
"Copy {value}" = "复制 {value}"
|
||||
"Corner radius" = "圆角半径"
|
||||
"Corner smoothing" = "圆角平滑"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "无法连接 {server}:显示的是这台电脑记录的内容。"
|
||||
"Count" = "数量"
|
||||
"Create" = "创建"
|
||||
"Create a variable collection first" = "请先创建变量集合"
|
||||
|
|
@ -403,6 +420,7 @@ status = "draft — needs native review"
|
|||
"Empty" = "空"
|
||||
"End" = "末尾"
|
||||
"End point" = "终点"
|
||||
"Ends the session; everyone keeps their copy" = "结束会话;每个人保留自己的副本"
|
||||
"Enter" = "Enter"
|
||||
"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "输入 figcraft-server 的地址,然后按“浏览”查看它提供的插件。"
|
||||
"Enter to save · Esc to cancel" = "Enter 保存 · Esc 取消"
|
||||
|
|
@ -428,6 +446,7 @@ status = "draft — needs native review"
|
|||
"Export {n} layers" = { other = "导出 {n} 个图层" }
|
||||
"Exported {file}" = "已导出 {file}"
|
||||
"Exported {name}" = "已导出 {name}"
|
||||
"Exported {name}; {n} kinds of content were left out" = { other = "已导出 {name};有 {n} 类内容未包含" }
|
||||
"Exported {n} files" = { other = "已导出 {n} 个文件" }
|
||||
"Export…" = "导出…"
|
||||
"Expose properties to instances" = "向实例公开属性"
|
||||
|
|
@ -442,6 +461,9 @@ status = "draft — needs native review"
|
|||
"Fetching from the server…" = "正在从服务器获取…"
|
||||
"File" = "文件"
|
||||
"File name" = "文件名"
|
||||
"File published" = "文件已发布"
|
||||
"Files aren't end-to-end encrypted: the server's operator can read them." = "文件未经端到端加密:服务器的运营者可以读取。"
|
||||
"Files nobody opens for {n} days are deleted." = { other = "{n} 天内无人打开的文件会被删除。" }
|
||||
"Fill" = "填充"
|
||||
"Fill (fr)" = "填充(fr)"
|
||||
"Fill container" = "填充容器"
|
||||
|
|
@ -484,6 +506,7 @@ status = "draft — needs native review"
|
|||
"Font not installed" = "字体未安装"
|
||||
"Font size" = "字号"
|
||||
"Font style" = "字体样式"
|
||||
"Forget on this computer" = "在这台电脑上忘记"
|
||||
"Fractions" = "分数"
|
||||
"Frame" = "画框"
|
||||
"Frame selection" = "为所选项添加画框"
|
||||
|
|
@ -497,6 +520,7 @@ status = "draft — needs native review"
|
|||
"Gap between columns" = "列间距"
|
||||
"Gap between items" = "项目间距"
|
||||
"Gap between rows" = "行间距"
|
||||
"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "请输入对公众开放的服务器地址,或带密钥的服务器地址(`figcraft-server key` 可显示)。"
|
||||
"Glass" = "玻璃"
|
||||
"Go to main component" = "转到主组件"
|
||||
"Go to {name}" = "转到 {name}"
|
||||
|
|
@ -520,6 +544,7 @@ status = "draft — needs native review"
|
|||
"Help" = "帮助"
|
||||
"Help and resources" = "帮助和资源"
|
||||
"Help page" = "帮助页面"
|
||||
"Here: {names}" = "在场:{names}"
|
||||
"Hidden" = "已隐藏"
|
||||
"Hide contents" = "隐藏内容"
|
||||
"Hide from publishing" = "发布时隐藏"
|
||||
|
|
@ -539,6 +564,7 @@ status = "draft — needs native review"
|
|||
"Horizontal hex" = "水平六边形"
|
||||
"Horizontal padding" = "水平内边距"
|
||||
"Horizontal spacing" = "水平间距"
|
||||
"Hosted by {operator}" = "由 {operator} 托管"
|
||||
"Hover a layer to see its properties" = "悬停在图层上查看其属性"
|
||||
"Hover other layers to measure the distance" = "悬停在其他图层上测量距离"
|
||||
"Hue" = "色相"
|
||||
|
|
@ -589,8 +615,11 @@ status = "draft — needs native review"
|
|||
"Invite link copied" = "已复制邀请链接"
|
||||
"It asks permission to:" = "它请求以下权限:"
|
||||
"Join" = "加入"
|
||||
"Join code copied" = "已复制加入代码"
|
||||
"Join code or invite link" = "加入代码或邀请链接"
|
||||
"Join live session" = "加入实时会话"
|
||||
"Join us on Discord" = "加入我们的 Discord"
|
||||
"Join with a code" = "用代码加入"
|
||||
"Joined" = "已加入"
|
||||
"Joined {file}" = "已加入 {file}"
|
||||
"Joining…" = "正在加入…"
|
||||
|
|
@ -653,6 +682,7 @@ status = "draft — needs native review"
|
|||
"Live session" = "实时会话"
|
||||
"Live · {people}" = "实时 · {people}"
|
||||
"Local components" = "本地组件"
|
||||
"Local network" = "本地网络"
|
||||
"Local styles" = "本地样式"
|
||||
"Local variables" = "本地变量"
|
||||
"Lock/Unlock" = "锁定/解锁"
|
||||
|
|
@ -742,6 +772,7 @@ status = "draft — needs native review"
|
|||
"No conflicts." = "没有冲突。"
|
||||
"No contributor data was built into this copy." = "此副本未内置贡献者数据。"
|
||||
"No fonts match" = "没有匹配的字体"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "此处没有加入代码(服务器版本早于加入代码,或此地址无法写入代码):请改为分享链接。"
|
||||
"No layer changes to show." = "没有可显示的图层更改。"
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "还没有库。添加已发布为库的 .figcraft 文件,或粘贴团队库的链接。"
|
||||
"No list" = "无列表"
|
||||
|
|
@ -780,9 +811,11 @@ status = "draft — needs native review"
|
|||
"Not in this font" = "此字体不支持"
|
||||
"Not properties, kept as written: {names}" = "不是属性,按原样保留:{names}"
|
||||
"Not read yet" = "尚未读取"
|
||||
"Not shared" = "未分享"
|
||||
"Note {n}" = "备注 {n}"
|
||||
"Nothing changed since this was marked ready for dev." = "自标记为可供开发以来没有任何更改。"
|
||||
"Nothing here yet. Set a frame's status to list it." = "这里还没有内容。设置画框的状态即可将其列出。"
|
||||
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "还没有在线文件。对文件使用“分享”将其放到网上后,它会列在这里。"
|
||||
"Nothing to export" = "没有可导出的内容"
|
||||
"Now" = "现在"
|
||||
"Nudge (Shift: big nudge)" = "微移(Shift:大幅微移)"
|
||||
|
|
@ -799,6 +832,9 @@ status = "draft — needs native review"
|
|||
"On click" = "点击时"
|
||||
"On drag" = "拖动时"
|
||||
"On this page" = "在此页面"
|
||||
"Online" = "在线"
|
||||
"Online files" = "在线文件"
|
||||
"Online sharing is off: files are shared on the local network only." = "在线分享已关闭:文件只在本地网络中分享。"
|
||||
"Only the first changed layers are listed." = "仅列出前几个已更改的图层。"
|
||||
"Only the first {n} changed layers are listed." = "仅列出前 {n} 个已更改的图层。"
|
||||
"Only your threads" = "仅你的讨论"
|
||||
|
|
@ -822,6 +858,9 @@ status = "draft — needs native review"
|
|||
"Open|comments" = "未解决"
|
||||
"Options" = "选项"
|
||||
"Orange" = "橙"
|
||||
"Other apps may not open every .fig file exported here." = "其他应用不一定能打开此处导出的所有 .fig 文件。"
|
||||
"Others lose access and keep their copy; the file stays on the server for you" = "其他人将失去访问权限并保留各自的副本;文件仍为你保留在服务器上"
|
||||
"Others type this under Share ▸ Join with a code" = "其他人在“分享 ▸ 用代码加入”中输入它"
|
||||
"Outline stroke" = "轮廓化描边"
|
||||
"Outline text" = "轮廓化文本"
|
||||
"Outlines" = "轮廓"
|
||||
|
|
@ -831,6 +870,8 @@ status = "draft — needs native review"
|
|||
"Overlay position" = "浮层位置"
|
||||
"Overridden here (the collection it extends has another value)" = "已在此覆盖(所扩展的集合中为其他值)"
|
||||
"Overrides" = "覆盖"
|
||||
"Owner token" = "所有者令牌"
|
||||
"Owner token copied" = "已复制所有者令牌"
|
||||
"PDF print options" = "PDF 打印选项"
|
||||
"PDF print settings" = "PDF 打印设置"
|
||||
"Padding" = "内边距"
|
||||
|
|
@ -857,6 +898,8 @@ status = "draft — needs native review"
|
|||
"Pen" = "钢笔"
|
||||
"Pencil" = "铅笔"
|
||||
"People in this file" = "此文件中的成员"
|
||||
"People join from anywhere with a code. The file is kept on {server} while you share it." = "任何地方的人都可以用代码加入。分享期间,文件保存在 {server} 上。"
|
||||
"People on your network join with a code. Nothing goes through a server." = "你的网络中的人用代码加入。不经过任何服务器。"
|
||||
"Phone" = "手机"
|
||||
"Pick color" = "取色"
|
||||
"Pick color (I)" = "取色(I)"
|
||||
|
|
@ -877,6 +920,7 @@ status = "draft — needs native review"
|
|||
"Points" = "点数"
|
||||
"Polygon" = "多边形"
|
||||
"Polygons" = "多边形"
|
||||
"Port" = "端口"
|
||||
"Position" = "位置"
|
||||
"Post" = "发布"
|
||||
"Preferences" = "偏好设置"
|
||||
|
|
@ -905,12 +949,14 @@ status = "draft — needs native review"
|
|||
"Prototype settings" = "原型设置"
|
||||
"Publish" = "发布"
|
||||
"Publish changes" = "发布更改"
|
||||
"Publish to another server…" = "发布到其他服务器…"
|
||||
"Publish to server" = "发布到服务器"
|
||||
"Published version {v}" = "已发布版本 {v}"
|
||||
"Published version {v} to the server" = "已将版本 {v} 发布到服务器"
|
||||
"Published version {v}: save the file so other files can use it" = "已发布版本 {v}:请保存文件,以便其他文件使用"
|
||||
"Published · {people}" = "已发布 · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "发布到服务器需要桌面应用;在浏览器中可以加入服务器上的文件"
|
||||
"Publishing with the server's key." = "使用服务器密钥发布。"
|
||||
"Publishing…" = "正在发布…"
|
||||
"Push" = "推入"
|
||||
"Push changes to main component" = "将更改推送到主组件"
|
||||
|
|
@ -938,6 +984,8 @@ status = "draft — needs native review"
|
|||
"Remove auto layout" = "移除自动布局"
|
||||
"Remove dev resource" = "移除开发资源"
|
||||
"Remove export setting" = "移除导出设置"
|
||||
"Remove from server" = "从服务器移除"
|
||||
"Remove from server…" = "从服务器移除…"
|
||||
"Remove guide" = "移除参考线"
|
||||
"Remove interaction" = "移除交互"
|
||||
"Remove library from this file" = "从此文件中移除库"
|
||||
|
|
@ -951,7 +999,10 @@ status = "draft — needs native review"
|
|||
"Remove text from path" = "从路径上移除文本"
|
||||
"Remove the else and its actions" = "移除“否则”及其操作"
|
||||
"Remove this condition and its actions" = "移除此条件及其操作"
|
||||
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "要从服务器移除此文件吗?之后将无法再从服务器分享或打开它。你的副本仍在此处打开。"
|
||||
"Remove {name} from {server}?" = "要从 {server} 移除 {name} 吗?"
|
||||
"Removed" = "已删除"
|
||||
"Removed from the server" = "已从服务器移除"
|
||||
"Removed since it was marked" = "标记后已删除"
|
||||
"Rename" = "重命名"
|
||||
"Rename duplicated layers" = "重命名副本图层"
|
||||
|
|
@ -959,6 +1010,8 @@ status = "draft — needs native review"
|
|||
"Rename group" = "重命名组"
|
||||
"Rename layers" = "重命名图层"
|
||||
"Rename mode" = "重命名模式"
|
||||
"Rename on the server" = "在服务器上重命名"
|
||||
"Rename on the server…" = "在服务器上重命名…"
|
||||
"Rename page" = "重命名页面"
|
||||
"Rename to" = "重命名为"
|
||||
"Replace all" = "全部替换"
|
||||
|
|
@ -966,6 +1019,8 @@ status = "draft — needs native review"
|
|||
"Replace with" = "替换为"
|
||||
"Replace with a new interaction" = "替换为新交互"
|
||||
"Reply" = "回复"
|
||||
"Report" = "举报"
|
||||
"Report this file…" = "举报此文件…"
|
||||
"Reset" = "重置"
|
||||
"Reset adjustments" = "重置调整"
|
||||
"Reset all changes" = "重置所有更改"
|
||||
|
|
@ -1055,9 +1110,11 @@ status = "draft — needs native review"
|
|||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "选框,{w} × {h},位于 {x}, {y}:方向键移动,Shift+方向键或 {cmd}+方向键调整大小,Enter 选择,Esc 取消"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "选框:方向键移动,Shift+方向键或 {cmd}+方向键调整大小,Enter 选择,Esc 取消"
|
||||
"Send backward" = "下移一层"
|
||||
"Send report" = "发送举报"
|
||||
"Send to back" = "置于底层"
|
||||
"Server address" = "服务器地址"
|
||||
"Server address (ws://host:47900/)" = "服务器地址(ws://host:47900/)"
|
||||
"Server for online sharing" = "在线分享所用的服务器"
|
||||
"Set as default" = "设为默认"
|
||||
"Set variable" = "设置变量"
|
||||
"Set variable mode" = "设置变量模式"
|
||||
|
|
@ -1068,7 +1125,15 @@ status = "draft — needs native review"
|
|||
"Shape builder" = "形状生成器"
|
||||
"Shape tools" = "形状工具"
|
||||
"Share" = "分享"
|
||||
"Share this file" = "分享此文件"
|
||||
"Share: live session, export" = "分享:实时会话、导出"
|
||||
"Shared" = "已分享"
|
||||
"Shared: copy its code from the file's menu" = "已分享:可在文件的菜单中复制代码"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "分享文件需要桌面应用;在这里你可以加入他人分享的文件。"
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "正在本地网络中分享(在线分享已关闭:偏好设置中未指定服务器)。"
|
||||
"Sharing on your network · {people}" = "正在你的网络中分享 · {people}"
|
||||
"Sharing online · {people}" = "正在在线分享 · {people}"
|
||||
"Sharing stopped: the file stays on the server, open only to you" = "已停止分享:文件保留在服务器上,只有你能打开"
|
||||
"Show" = "显示"
|
||||
"Show as grid" = "以网格显示"
|
||||
"Show as list" = "以列表显示"
|
||||
|
|
@ -1115,6 +1180,7 @@ status = "draft — needs native review"
|
|||
"Space evenly" = "均匀间距"
|
||||
"Space+drag" = "空格+拖动"
|
||||
"Spacing" = "间距"
|
||||
"Spam, stolen work, abuse…" = "垃圾信息、盗用作品、滥用…"
|
||||
"Splay" = "张开"
|
||||
"Spotlight me" = "聚焦于我"
|
||||
"Spread" = "扩展"
|
||||
|
|
@ -1128,11 +1194,14 @@ status = "draft — needs native review"
|
|||
"Start from a blank canvas" = "从空白画布开始"
|
||||
"Start on path" = "路径起点"
|
||||
"Start point" = "起点"
|
||||
"Start sharing" = "开始分享"
|
||||
"Starting to share…" = "正在开始分享…"
|
||||
"Status" = "状态"
|
||||
"Sticky" = "粘性"
|
||||
"Stop" = "停止"
|
||||
"Stop following {name}" = "停止跟随 {name}"
|
||||
"Stop session" = "停止会话"
|
||||
"Stop sharing" = "停止分享"
|
||||
"Stop spotlighting" = "停止聚焦"
|
||||
"Stop {n} {hex}" = "色标 {n} {hex}"
|
||||
"Stretch" = "拉伸"
|
||||
|
|
@ -1171,8 +1240,10 @@ status = "draft — needs native review"
|
|||
"Take the values from the first selected layer" = "取第一个所选图层的值"
|
||||
"Taper" = "渐细"
|
||||
"Taper both ends" = "两端渐细"
|
||||
"Tell the server's operator what is wrong with it" = "告诉服务器的运营者它有什么问题"
|
||||
"Temperature" = "色温"
|
||||
"Template" = "模板"
|
||||
"Terms" = "条款"
|
||||
"Text" = "文本"
|
||||
"Text align" = "文本对齐"
|
||||
"Text content" = "文本内容"
|
||||
|
|
@ -1181,20 +1252,31 @@ status = "draft — needs native review"
|
|||
"Texts" = "文本"
|
||||
"Texture" = "纹理"
|
||||
"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "画布以 sRGB 预览颜色;导出的 PNG、JPG 和 WebP 文件标记为 Display P3。"
|
||||
"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "文件将从服务器删除:任何人都无法再从服务器打开或加入它,且无法撤销。已打开的副本仍归各自持有者所有。"
|
||||
"The file now holds another library" = "该文件现在包含另一个库"
|
||||
"The file now holds another library (\"{name}\")" = "该文件现在包含另一个库(“{name}”)"
|
||||
"The files you shared or published online from this computer. They stay on their server until you remove them." = "你从这台电脑在线分享或发布的文件。在你移除之前,它们会一直保留在各自的服务器上。"
|
||||
"The link carries the session's secret: share it only with people you invite." = "链接包含会话密钥:只分享给你邀请的人。"
|
||||
"The name in your online files and on the server. The document inside keeps its own name." = "在线文件列表和服务器上显示的名称。其中的文档保留自己的名称。"
|
||||
"The selection uses several text styles: click to apply one" = "所选项使用了多种文本样式:点击应用其中一种"
|
||||
"The server deletes this file if nobody opens it before {date}." = "如果在 {date} 之前无人打开,服务器会删除此文件。"
|
||||
"The whole file" = "整个文件"
|
||||
"Theme" = "主题"
|
||||
"There's no destination for that action" = "该操作没有目标"
|
||||
"Thickness" = "粗细"
|
||||
"This branch can't be reviewed: {e}" = "无法审阅此分支:{e}"
|
||||
"This branch was merged or archived." = "此分支已合并或已归档。"
|
||||
"This computer only" = "仅限这台电脑"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "这台电脑会记住所有者令牌,因此可以在主屏幕的“在线”列表中管理此文件。如需在别处管理此文件,请复制它:它相当于此文件的密码,且不会再次显示。"
|
||||
"This condition is edited as an expression" = "此条件以表达式编辑"
|
||||
"This file" = "此文件"
|
||||
"This file has unsaved changes" = "此文件有未保存的更改"
|
||||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "此文件在 {server} 上,只有你能打开。分享后,其他人可以用新代码加入。"
|
||||
"This frame is new on the branch." = "此画框是分支上新增的。"
|
||||
"This frame isn't on the branch." = "此画框不在分支上。"
|
||||
"This server is open to the public: no key needed." = "此服务器对公众开放:无需密钥。"
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "此服务器需要密钥:请在地址后加上 ?key=…(在服务器所在的机器上运行 `figcraft-server key` 可显示)。"
|
||||
"This server takes files only with its key: see Advanced." = "此服务器只接受带密钥的文件:请查看“高级”。"
|
||||
"Tidy up" = "整理"
|
||||
"Tile" = "平铺"
|
||||
"Tile type" = "平铺类型"
|
||||
|
|
@ -1216,10 +1298,12 @@ status = "draft — needs native review"
|
|||
"True" = "真"
|
||||
"Truncate text" = "截断文本"
|
||||
"Truncate with an ellipsis" = "以省略号截断"
|
||||
"Try again" = "重试"
|
||||
"Try another search or filter." = "请尝试其他搜索词或筛选条件。"
|
||||
"Two digits" = "两位数"
|
||||
"Type" = "类型"
|
||||
"Type settings" = "文字设置"
|
||||
"Type the join code you were given, or paste an invite link." = "输入你收到的加入代码,或粘贴邀请链接。"
|
||||
"Typography" = "排版"
|
||||
"Underline" = "下划线"
|
||||
"Undo takes back only your own changes; what others changed since stays." = "撤销仅撤回你自己的更改;他人之后的更改会保留。"
|
||||
|
|
@ -1291,11 +1375,13 @@ status = "draft — needs native review"
|
|||
"Wedge" = "楔形"
|
||||
"Weight" = "粗细"
|
||||
"What changed since it was marked ready for dev" = "自标记为可供开发以来的更改"
|
||||
"What is wrong with this file? The server's operator gets your report." = "此文件有什么问题?你的举报会发送给服务器的运营者。"
|
||||
"What it's for" = "用途"
|
||||
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "无法得知更改前的状态:它在 FigCraft 开始保存可供开发画框的副本之前就已更改。请解决此更改(或重新标记为可供开发),以便今后进行比较。"
|
||||
"While hovering" = "悬停时"
|
||||
"While pressing" = "按住时"
|
||||
"White" = "白"
|
||||
"Who runs it, its terms and how long it keeps files" = "谁在运营、其条款以及文件保留多久"
|
||||
"Why it changed" = "更改原因"
|
||||
"Widgets" = "小组件"
|
||||
"Width" = "宽度"
|
||||
|
|
@ -1307,8 +1393,10 @@ status = "draft — needs native review"
|
|||
"Yellow" = "黄"
|
||||
"Yesterday" = "昨天"
|
||||
"You" = "你"
|
||||
"You'll get the shared file as a new tab and edit it together." = "分享的文件会在新标签页中打开,你们可以一起编辑。"
|
||||
"You're spotlighting: everyone follows your view" = "你正在聚焦:所有人都在跟随你的视图"
|
||||
"Your copy of the file stays open" = "你的文件副本保持打开"
|
||||
"Your copy stays open; the file stays on the server" = "你的副本保持打开;文件保留在服务器上"
|
||||
"Your name" = "你的名字"
|
||||
"Your name in comments" = "评论中显示的名字"
|
||||
"Your system asks for reduced motion: animations stay off." = "系统要求减少动态效果:动画保持关闭。"
|
||||
|
|
@ -1353,6 +1441,9 @@ status = "draft — needs native review"
|
|||
"{instances} in {files}" = "{files} 中的 {instances}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}:翻译草稿,等待母语者审校。"
|
||||
"{length} bleed" = "出血 {length}"
|
||||
"{name} cleared from the list" = "已从列表中清除 {name}"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "已在这台电脑上忘记 {name};文件仍保留在 {server} 上"
|
||||
"{name} is on the server." = "{name} 已在服务器上。"
|
||||
"{name} is spotlighting" = "{name} 正在聚焦"
|
||||
"{name}\nClick to apply to the selection" = "{name}\n点击应用于所选项"
|
||||
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\n拖到画布上,或点击放置在视图中央。\n拖放到实例上时按住 Alt(Option)可替换该实例。"
|
||||
|
|
@ -1361,12 +1452,15 @@ status = "draft — needs native review"
|
|||
"{name} ({layers} layers)" = "{name}({layers} 个图层)"
|
||||
"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name}({tag}):{min} – {max},默认 {default}。双击滑块恢复为样式的值;拖动时按 Esc 取消。"
|
||||
"{name} from {path}" = "来自 {path} 的 {name}"
|
||||
"{name} was removed from the server" = "已从服务器移除 {name}"
|
||||
"{name} · deleted on main" = "{name} · 已在主文件中删除"
|
||||
"{name} · deleted on the branch" = "{name} · 已在分支中删除"
|
||||
"{name} · not published" = "{name} · 未发布"
|
||||
"{name} · on a server" = "{name} · 在服务器上"
|
||||
"{name} · published version {v}" = "{name} · 已发布版本 {v}"
|
||||
"{name} · version {v} on the server" = "{name} · 服务器上的版本 {v}"
|
||||
"{name}, in a live session" = "{name},正在实时会话中"
|
||||
"{name}, online, open only to you" = "{name},在线,只有你能打开"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name},{kind},{w} × {h},已选中"
|
||||
"{n} added" = { other = "新增 {n} 个" }
|
||||
"{n} autosave versions" = { other = "{n} 个自动保存版本" }
|
||||
|
|
@ -1404,6 +1498,9 @@ status = "draft — needs native review"
|
|||
"{page}, nothing selected" = "{page},未选择任何内容"
|
||||
"{profile} (current)" = "{profile}(当前)"
|
||||
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} 表示其值;{property ? \"a\" : \"b\"} 选择一个字符串。其他 {…} 按原样保留。"
|
||||
"{server} can't say what became of your files (it is older than this app)." = "{server} 无法告知你的文件的状况(它的版本早于此应用)。"
|
||||
"{server} · No longer on the server" = "{server} · 已不在服务器上"
|
||||
"{server} · {shared} · Opened {date}" = "{server} · {shared} · 打开于 {date}"
|
||||
"{tool} hold {key}" = "{tool} 按住 {key}"
|
||||
"{value} isn't a colour" = "{value} 不是颜色"
|
||||
"{when} · edited" = "{when} · 已编辑"
|
||||
|
|
@ -1462,13 +1559,20 @@ status = "draft — needs native review"
|
|||
"collab.cursor" = "在指定位置显示光标"
|
||||
"collab.cursorChat" = "光标聊天"
|
||||
"collab.diff" = "显示同步更改"
|
||||
"collab.files" = "你的在线文件"
|
||||
"collab.follow" = "跟随"
|
||||
"collab.forgetFile" = "忘记在线文件"
|
||||
"collab.host" = "开始实时会话"
|
||||
"collab.join" = "加入实时会话"
|
||||
"collab.leave" = "离开实时会话"
|
||||
"collab.manageFile" = "管理已发布的文件"
|
||||
"collab.openFile" = "打开在线文件"
|
||||
"collab.peers" = "此文件中的成员"
|
||||
"collab.publish" = "发布到服务器"
|
||||
"collab.registerLinks" = "在 FigCraft 中打开邀请链接"
|
||||
"collab.report" = "向服务器运营者举报文件"
|
||||
"collab.serverInfo" = "服务器信息"
|
||||
"collab.share" = "用加入代码分享"
|
||||
"collab.spotlight" = "聚焦于我"
|
||||
"collab.stop" = "停止实时会话"
|
||||
"command.batch" = "运行多个命令"
|
||||
|
|
@ -1687,6 +1791,7 @@ status = "draft — needs native review"
|
|||
"pref.language" = "界面语言"
|
||||
"pref.nudge" = "微移量"
|
||||
"pref.numberKeysOpacity" = "用数字键设置不透明度"
|
||||
"pref.publishServer" = "在线分享所用的服务器"
|
||||
"pref.reduceMotion" = "减少动态效果"
|
||||
"pref.renameDuplicates" = "重命名副本图层"
|
||||
"pref.showDimensions" = "在对象上显示尺寸"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
//! for the frames that follow (all false: released); `gamepad` presses a controller button in the
|
||||
//! prototype playing (presenter or preview pane)
|
||||
//! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog)
|
||||
//! - `ui.set {rightTab?, leftTab?, theme?, home?, palette?, focusRegion?, preview?, presentScroll?, …}`
|
||||
//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, palette?, focusRegion?, preview?, presentScroll?, …}`
|
||||
//! - `ui.screenshot {path}` — PNG of the window (needs a visible window)
|
||||
//! - `ui.render {path, scale?, ids?}` — PNG of the document (no UI chrome; works without a window)
|
||||
//! - `window.new {path?, index?}` → `{window}` / `window.list {}` / `window.focus {id}` — several
|
||||
|
|
@ -344,6 +344,9 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
|
|||
if let Some(h) = p.get("home").and_then(Value::as_bool) {
|
||||
app.ui.home = h;
|
||||
}
|
||||
if let Some(s) = p.get("homeSection").and_then(Value::as_str) {
|
||||
app.ui.online.open = s == "online";
|
||||
}
|
||||
if let Some(h) = p.get("palette").and_then(Value::as_bool) {
|
||||
app.ui.palette = if h { Some(String::new()) } else { None };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -290,6 +290,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
|||
crate::plugins_ui::show(app, ctx);
|
||||
crate::live::join_dialog(app, ctx);
|
||||
crate::live::publish_dialog(app, ctx);
|
||||
crate::online::dialogs(app, ctx);
|
||||
crate::windows::confirm_close(app, ctx);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,40 @@
|
|||
//! The home screen: new file, open, join with a code, recent files, templates.
|
||||
//! The home screen: new file, open, join with a code, recent files, and your online files.
|
||||
|
||||
use egui::{Align2, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::a11y;
|
||||
use crate::i18n::{tr, tr_noop};
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{ACCENT, Tokens, regular, semibold};
|
||||
use crate::widgets;
|
||||
|
||||
/// A row of the home screen's file lists.
|
||||
pub(crate) struct FileRow<'a> {
|
||||
/// The mark before the name, and its colour.
|
||||
pub mark: Option<(Icon, Color32)>,
|
||||
pub name: &'a str,
|
||||
pub ink: Color32,
|
||||
/// What its right end says (a path, where the file is), with its size and colour.
|
||||
pub detail: &'a str,
|
||||
pub detail_size: f32,
|
||||
pub detail_ink: Color32,
|
||||
}
|
||||
|
||||
/// Paint `row` in `r` (highlighted while `resp` is hovered): the name gives way to the detail.
|
||||
pub(crate) fn file_row(ui: &Ui, t: &Tokens, r: Rect, resp: &Response, row: &FileRow) {
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
if let Some((icon, tint)) = row.mark {
|
||||
icons::paint(ui.painter(), Rect::from_min_size(pos2(r.min.x + 8.0, r.center().y - 8.0), vec2(16.0, 16.0)), icon, tint);
|
||||
}
|
||||
let detail = ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, row.detail, regular(row.detail_size), row.detail_ink);
|
||||
let name = widgets::elide_font(ui, row.name, (detail.min.x - r.min.x - 44.0).max(40.0), regular(12.0));
|
||||
ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, name, regular(12.0), row.ink);
|
||||
}
|
||||
|
||||
pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| {
|
||||
|
|
@ -20,9 +46,26 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
|||
icons::paint(ui.painter(), Rect::from_min_size(pos2(left.min.x + 16.0, y), vec2(20.0, 20.0)), Icon::Logo, ACCENT);
|
||||
ui.painter().text(pos2(left.min.x + 44.0, y + 10.0), Align2::LEFT_CENTER, "FigCraft", semibold(14.0), t.text);
|
||||
y += 48.0;
|
||||
for (icon, label) in [(Icon::Home, tr_noop("Recents")), (Icon::Page, tr_noop("Drafts")), (Icon::Assets, tr_noop("Community"))] {
|
||||
// Recents and Online are the sections there are; the others are placeholders.
|
||||
for (icon, label, section) in [
|
||||
(Icon::Home, tr_noop("Recents"), Some(false)),
|
||||
(Icon::Online, tr_noop("Online"), Some(true)),
|
||||
(Icon::Page, tr_noop("Drafts"), None),
|
||||
(Icon::Assets, tr_noop("Community"), None),
|
||||
] {
|
||||
let r = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(224.0, 32.0));
|
||||
if label == "Recents" {
|
||||
let selected = section == Some(app.ui.online.open);
|
||||
if let Some(online) = section {
|
||||
let resp = ui.interact(r, ui.id().with(("home_section", label)), Sense::click());
|
||||
a11y::tab(&resp, tr(label), selected);
|
||||
if resp.clicked() {
|
||||
app.ui.online.open = online;
|
||||
}
|
||||
if resp.hovered() && !selected {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
}
|
||||
if selected {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.selected_row);
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_min_size(pos2(r.min.x + 10.0, r.center().y - 8.0), vec2(16.0, 16.0)), icon, t.icon);
|
||||
|
|
@ -30,6 +73,11 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
|||
y += 34.0;
|
||||
}
|
||||
let main = Rect::from_min_max(pos2(left.max.x + 40.0, rect.min.y + 24.0), rect.max - vec2(40.0, 24.0));
|
||||
if app.ui.online.open {
|
||||
crate::online::section(app, ui, main, &t);
|
||||
opened_join(app);
|
||||
return;
|
||||
}
|
||||
ui.painter().text(main.min, Align2::LEFT_TOP, tr("Recents"), semibold(18.0), t.text);
|
||||
// Action cards.
|
||||
let mut x = main.min.x;
|
||||
|
|
@ -83,15 +131,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
|||
crate::live::join_status(app, ui, &t, session);
|
||||
}
|
||||
});
|
||||
// The shared file opened in a tab of its own: show it. (A join that was left is forgotten.)
|
||||
match app.ui.live.home_join.map(|id| app.session.live.get(id).map(figcraft_engine::live::Live::syncing)) {
|
||||
Some(Some(true)) => {
|
||||
app.ui.live.home_join = None;
|
||||
app.ui.home = false;
|
||||
}
|
||||
Some(None) => app.ui.live.home_join = None,
|
||||
_ => {}
|
||||
}
|
||||
opened_join(app);
|
||||
// Open documents and recent files.
|
||||
let mut y = join.max.y + 22.0;
|
||||
ui.painter().text(pos2(main.min.x, y), Align2::LEFT_TOP, tr("Open files"), semibold(12.0), t.text_secondary);
|
||||
|
|
@ -100,14 +140,10 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
|||
for (i, d) in app.session.docs.iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(main.min.x, y), vec2(main.width().min(640.0), 36.0));
|
||||
let resp = ui.interact(r, ui.id().with(("open", d.uid)), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_min_size(pos2(r.min.x + 8.0, r.center().y - 8.0), vec2(16.0, 16.0)), Icon::Logo, ACCENT);
|
||||
ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, &d.doc.name, regular(12.0), t.text);
|
||||
if let Some(p) = &d.path {
|
||||
ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, p, regular(11.0), t.text_secondary);
|
||||
}
|
||||
let detail = d.path.as_deref().unwrap_or_default();
|
||||
let row =
|
||||
FileRow { mark: Some((Icon::Logo, ACCENT)), name: &d.doc.name, ink: t.text, detail, detail_size: 11.0, detail_ink: t.text_secondary };
|
||||
file_row(ui, &t, r, &resp, &row);
|
||||
if resp.clicked() {
|
||||
switch = Some(i);
|
||||
}
|
||||
|
|
@ -126,12 +162,14 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
|||
for p in recents.iter().take(12) {
|
||||
let r = Rect::from_min_size(pos2(main.min.x, y), vec2(main.width().min(640.0), 32.0));
|
||||
let resp = ui.interact(r, ui.id().with(("recent", p.as_str())), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
let name = std::path::Path::new(p).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| p.clone());
|
||||
ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, name, regular(12.0), t.text);
|
||||
ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, p, regular(10.0), t.text_tertiary);
|
||||
file_row(
|
||||
ui,
|
||||
&t,
|
||||
r,
|
||||
&resp,
|
||||
&FileRow { mark: None, name: &name, ink: t.text, detail: p, detail_size: 10.0, detail_ink: t.text_tertiary },
|
||||
);
|
||||
if resp.clicked() {
|
||||
open_path = Some(p.clone());
|
||||
}
|
||||
|
|
@ -145,3 +183,16 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The file joined from the home screen (a code, or one of your online files) opened in a tab of
|
||||
/// its own: show it. (A join that was left is forgotten.)
|
||||
fn opened_join(app: &mut FigApp) {
|
||||
match app.ui.live.home_join.map(|id| app.session.live.get(id).map(figcraft_engine::live::Live::syncing)) {
|
||||
Some(Some(true)) => {
|
||||
app.ui.live.home_join = None;
|
||||
app.ui.home = false;
|
||||
}
|
||||
Some(None) => app.ui.live.home_join = None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,6 +174,12 @@ icons! {
|
|||
ArcRatio,
|
||||
/// A colour outside the screen's gamut: a triangle with an exclamation mark.
|
||||
GamutWarning,
|
||||
/// Online: a globe (a circle with its equator and a meridian).
|
||||
Online,
|
||||
/// In a live session others get into: a point sending waves to both sides.
|
||||
LiveShared,
|
||||
/// A file of yours on a server that only you get into: a point inside a closed ring.
|
||||
LivePrivate,
|
||||
}
|
||||
|
||||
/// Points along a circular arc from angle `a0` to `a1` (radians, y down: increasing is clockwise).
|
||||
|
|
@ -868,6 +874,23 @@ fn draw(p: &P, icon: Icon) {
|
|||
p.rect(3.5, 2.5, 12.5, 13.5, 1.0);
|
||||
p.fill_rect(6.0, 5.0, 10.0, 11.0, 0.0);
|
||||
}
|
||||
Online => {
|
||||
p.circle(8.0, 8.0, 5.5);
|
||||
p.line(2.5, 8.0, 13.5, 8.0);
|
||||
let meridian: Vec<(f32, f32)> = arc_pts(0.0, 0.0, 1.0, 0.0, TAU).map(|(x, y)| (8.0 + 2.4 * x, 8.0 + 5.5 * y)).collect();
|
||||
p.path(&meridian, true);
|
||||
}
|
||||
LiveShared => {
|
||||
p.dot(8.0, 8.0, 1.6);
|
||||
for r in [3.6, 6.0] {
|
||||
p.arc(8.0, 8.0, r, -FRAC_PI_4, FRAC_PI_4);
|
||||
p.arc(8.0, 8.0, r, PI - FRAC_PI_4, PI + FRAC_PI_4);
|
||||
}
|
||||
}
|
||||
LivePrivate => {
|
||||
p.dot(8.0, 8.0, 1.6);
|
||||
p.circle(8.0, 8.0, 5.0);
|
||||
}
|
||||
Clock => {
|
||||
p.circle(7.5, 7.5, 5.5);
|
||||
p.line(7.5, 4.0, 7.5, 8.0);
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ pub mod live;
|
|||
pub mod live_text;
|
||||
pub mod measure_overlay;
|
||||
pub mod menus;
|
||||
pub mod online;
|
||||
pub mod plugins_ui;
|
||||
pub mod present;
|
||||
pub mod proto_canvas;
|
||||
|
|
@ -106,6 +107,8 @@ mod live_tests;
|
|||
#[cfg(test)]
|
||||
mod live_text_tests;
|
||||
#[cfg(test)]
|
||||
mod online_tests;
|
||||
#[cfg(test)]
|
||||
mod panel_reorder_tests;
|
||||
#[cfg(test)]
|
||||
mod panel_width_tests;
|
||||
|
|
@ -371,6 +374,8 @@ pub struct UiState {
|
|||
pub plugin_form: Option<plugins_ui::PluginForm>,
|
||||
/// Live sessions: remote cursors as drawn, the Join dialog.
|
||||
pub live: live::LiveUi,
|
||||
/// Home ▸ Online.
|
||||
pub online: online::OnlineUi,
|
||||
/// The window was asked to close with unsaved changes: the question shows.
|
||||
pub confirm_close: bool,
|
||||
}
|
||||
|
|
@ -446,6 +451,7 @@ impl Default for UiState {
|
|||
plugins_manage: plugins_ui::PluginsWindow::default(),
|
||||
plugin_form: None,
|
||||
live: live::LiveUi::default(),
|
||||
online: online::OnlineUi::default(),
|
||||
confirm_close: false,
|
||||
}
|
||||
}
|
||||
|
|
@ -921,7 +927,8 @@ impl FigApp {
|
|||
}
|
||||
windows::show(self, &ctx);
|
||||
// Plugins installed, removed or set up are kept at once: a crash doesn't lose them.
|
||||
if std::mem::take(&mut self.session.plugins.unsaved) {
|
||||
// So are the owner tokens of files put online.
|
||||
if std::mem::take(&mut self.session.plugins.unsaved) | std::mem::take(&mut self.session.live.unsaved) {
|
||||
self.services.save_prefs(&self.session.prefs);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
|
@ -964,6 +971,7 @@ impl FigApp {
|
|||
home::show(self, ui);
|
||||
dialogs::keys(self, &ctx);
|
||||
dialogs::show(self, &ctx);
|
||||
self.toast_ui(&ctx);
|
||||
windows::requests(self, &ctx);
|
||||
return;
|
||||
}
|
||||
|
|
@ -1106,6 +1114,8 @@ impl FigApp {
|
|||
"rightTab": self.ui.right_tab,
|
||||
"leftTab": self.ui.left_tab,
|
||||
"home": self.ui.home,
|
||||
"online": online::inspect(self),
|
||||
"tabs": self.session.docs.iter().map(|d| json!({"name": d.doc.name, "live": tabs::live_mark(self, d.uid).map(|shared| if shared { "shared" } else { "private" })})).collect::<Vec<_>>(),
|
||||
"dark": self.ui.dark,
|
||||
"focus": {"region": self.ui.nav.region.id(), "keyboard": self.ui.nav.keyboard},
|
||||
"reduceMotion": self.reduce_motion(),
|
||||
|
|
|
|||
|
|
@ -43,9 +43,9 @@ const PUBLISHED_NOTE: &str = tr_noop(
|
|||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them.",
|
||||
);
|
||||
|
||||
/// Why the owner token matters (shown once, when a file is published without the server key).
|
||||
/// What the owner token is for (shown once, when a file is published without the server key).
|
||||
const OWNER_NOTE: &str = tr_noop(
|
||||
"Copy the owner token and keep it somewhere safe: it is the only way to revoke these links, rename the file or delete it from the server, and it won't be shown again.",
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again.",
|
||||
);
|
||||
|
||||
/// What a server open to the public can see.
|
||||
|
|
@ -780,14 +780,17 @@ fn private_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) {
|
|||
} else if widgets::button(ui, tr("Start sharing"), true, true).clicked() {
|
||||
app.run_background(&ui.ctx().clone(), SHARE, json!({"via": "online"}), Box::new(|_, _, _| {}));
|
||||
}
|
||||
// Whoever published it without the server's key holds its owner token: they can remove it.
|
||||
let published = app.session.live().published.clone();
|
||||
let removable = published.and_then(|p| Some((share.server.clone(), p.file, p.owner?)));
|
||||
// Whoever published it without the server's key holds its owner token (in this session, or
|
||||
// recorded with their online files): they can remove it.
|
||||
let removable = app.session.live().published.clone().map(|p| (share.server.clone(), p.file)).filter(|(server, file)| {
|
||||
app.session.live().published.as_ref().is_some_and(|p| p.owner.is_some())
|
||||
|| app.session.prefs.online_files.iter().any(|f| f.file == *file && figcraft_engine::online::same_server(&f.server, server))
|
||||
});
|
||||
join_and_advanced(app, ui, t, |app, ui, t| {
|
||||
if widgets::button(ui, tr("Leave session"), false, true).on_hover_text(tr("Your copy stays open; the file stays on the server")).clicked() {
|
||||
app.run("collab.leave", json!({}));
|
||||
}
|
||||
let Some((server, file, owner)) = removable else { return };
|
||||
let Some((server, file)) = removable else { return };
|
||||
if !std::mem::take(&mut app.ui.live.confirm_remove) {
|
||||
if widgets::button(ui, tr("Remove from server…"), false, true).clicked() {
|
||||
app.ui.live.confirm_remove = true;
|
||||
|
|
@ -801,9 +804,13 @@ fn private_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) {
|
|||
);
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::button(ui, tr("Remove"), true, true).clicked() {
|
||||
// Out of the file first, so its removal isn't this session's failure.
|
||||
app.run("collab.leave", json!({}));
|
||||
let params = json!({"server": server, "file": file, "owner": owner, "action": "remove"});
|
||||
// The owner token comes from the session or the record; the session ends with the
|
||||
// file.
|
||||
let owner = app.session.live().published.as_ref().and_then(|p| p.owner.clone());
|
||||
let mut params = json!({"server": server, "file": file, "action": "remove"});
|
||||
if let Some(owner) = owner {
|
||||
params["owner"] = json!(owner);
|
||||
}
|
||||
app.run_background(&ui.ctx().clone(), "collab.manageFile", params, Box::new(|app, _, _| app.toast(tr("Removed from the server"))));
|
||||
} else if !widgets::button(ui, tr("Cancel"), false, true).clicked() {
|
||||
app.ui.live.confirm_remove = true;
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ fn a_server_open_to_the_public_takes_a_file_without_a_key() {
|
|||
assert!(has_text(&shapes, "File published") && has_text(&shapes, "Owner token") && has_text(&shapes, owner));
|
||||
assert!(has_text(
|
||||
&shapes,
|
||||
"Copy the owner token and keep it somewhere safe: it is the only way to revoke these links, rename the file or delete it from the server, and it won't be shown again."
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again."
|
||||
));
|
||||
assert!(has_text(&shapes, "The server deletes this file if nobody opens it before 2027-01-15 08:00 UTC."));
|
||||
h.click(text_center(&shapes, "Done"));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,328 @@
|
|||
//! Home ▸ Online: the files you put on a server from this computer (`collab.files`), each with
|
||||
//! where it is, whether others get in and when it was last opened. A row opens its file as its
|
||||
//! owner; its menu starts or stops sharing, copies the join code or link, renames it on the
|
||||
//! server, removes it from the server (confirmed) or forgets it here. Nothing is asked of a
|
||||
//! server until the section opens or a file is acted on; then each server is asked once, in the
|
||||
//! background (the answer wakes the window), and one that can't be reached says so while the
|
||||
//! list shows what this computer remembers.
|
||||
|
||||
use egui::{Align2, CornerRadius, Rect, Sense, Ui, pos2, vec2};
|
||||
use figcraft_engine::live::utc_date;
|
||||
use figcraft_engine::online::{FileState, OnlineFile, ServerProblem, server_host};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::a11y::{self, Tip};
|
||||
use crate::home::{FileRow, file_row};
|
||||
use crate::i18n::{tr, trf};
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{Tokens, semibold};
|
||||
use crate::widgets;
|
||||
|
||||
const FILES: &str = "collab.files";
|
||||
const MANAGE: &str = "collab.manageFile";
|
||||
|
||||
/// A row's height.
|
||||
const ROW: f32 = 36.0;
|
||||
|
||||
/// UI-only state of the Online section.
|
||||
#[derive(Default)]
|
||||
pub struct OnlineUi {
|
||||
/// The Online section shows on the home screen (else Recents).
|
||||
pub open: bool,
|
||||
/// The frame the section last drew in: it asks the servers when it shows again after a gap.
|
||||
drawn: Option<u64>,
|
||||
/// Remove from server… was chosen for this file (server, id, name): its confirmation shows.
|
||||
pub remove: Option<(String, String, String)>,
|
||||
/// Rename… was chosen for this file (server, id) and the name being typed.
|
||||
pub rename: Option<(String, String, String)>,
|
||||
focus_rename: bool,
|
||||
}
|
||||
|
||||
/// What a row says after the file's name.
|
||||
fn detail(f: &OnlineFile) -> String {
|
||||
let host = server_host(&f.server);
|
||||
if f.state == FileState::Gone {
|
||||
return trf("{server} · No longer on the server", &[("server", &host)]);
|
||||
}
|
||||
let shared = if f.shared.is_some() { tr("Shared") } else { tr("Not shared") };
|
||||
let date = utc_date(f.opened);
|
||||
let day = date.get(..10).unwrap_or(&date);
|
||||
trf("{server} · {shared} · Opened {date}", &[("server", &host), ("shared", &shared), ("date", &day)])
|
||||
}
|
||||
|
||||
/// The mark of a file others get into (`true`), or one that only its owner does.
|
||||
pub fn mark(shared: bool) -> Icon {
|
||||
if shared { Icon::LiveShared } else { Icon::LivePrivate }
|
||||
}
|
||||
|
||||
/// Ask each server once what became of its files (in the background).
|
||||
fn refresh(app: &mut FigApp, ctx: &egui::Context) {
|
||||
if !app.session.prefs.online_files.is_empty() && !app.session.waiting_for(FILES) {
|
||||
app.run_background(ctx, FILES, json!({"refresh": true}), Box::new(|_, _, _| {}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `action` of `collab.manageFile` on recorded file `f` without the window waiting for the
|
||||
/// server; `done` gets the result.
|
||||
fn manage(app: &mut FigApp, ctx: &egui::Context, f: (&str, &str), action: &str, extra: Value, done: crate::Done) {
|
||||
let mut params = json!({"server": f.0, "file": f.1, "action": action});
|
||||
if let (Some(p), Some(e)) = (params.as_object_mut(), extra.as_object()) {
|
||||
p.extend(e.clone());
|
||||
}
|
||||
app.run_background(ctx, MANAGE, params, done);
|
||||
}
|
||||
|
||||
/// Open recorded file `f` as its owner; its tab shows once the file arrives.
|
||||
fn open(app: &mut FigApp, f: &OnlineFile) {
|
||||
let Some(r) = app.run("collab.openFile", json!({"server": f.server, "file": f.file})) else { return };
|
||||
if r["alreadyOpen"] == true && !r["document"].is_null() {
|
||||
app.ui.home = false;
|
||||
} else {
|
||||
app.ui.live.home_join = r["session"].as_u64();
|
||||
}
|
||||
}
|
||||
|
||||
/// The Online section of the home screen, in `main`.
|
||||
pub fn section(app: &mut FigApp, ui: &mut Ui, main: Rect, t: &Tokens) {
|
||||
let ctx = ui.ctx().clone();
|
||||
// Shown again after something else was: ask the servers.
|
||||
let frame = ctx.cumulative_frame_nr();
|
||||
if app.ui.online.drawn.replace(frame).is_none_or(|d| d.saturating_add(1) < frame) {
|
||||
refresh(app, &ctx);
|
||||
}
|
||||
ui.painter().text(main.min, Align2::LEFT_TOP, tr("Online files"), semibold(18.0), t.text);
|
||||
let body = Rect::from_min_max(pos2(main.min.x, main.min.y + 40.0), pos2(main.min.x + main.width().min(720.0), main.max.y));
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(body).layout(egui::Layout::top_down(egui::Align::Min)), |ui| {
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
|
||||
ui.spacing_mut().item_spacing.y = 6.0;
|
||||
widgets::label(
|
||||
ui,
|
||||
tr("The files you shared or published online from this computer. They stay on their server until you remove them."),
|
||||
t.text_secondary,
|
||||
);
|
||||
status(app, ui, t);
|
||||
if let Some(session) = app.ui.live.home_join {
|
||||
crate::live::join_status(app, ui, t, session);
|
||||
}
|
||||
let files = app.session.prefs.online_files.clone();
|
||||
if files.is_empty() {
|
||||
ui.add_space(8.0);
|
||||
widgets::label(ui, tr("Nothing online yet. Use Share on a file to put it online and it is listed here."), t.text_secondary);
|
||||
return;
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let busy = app.session.waiting_for(MANAGE);
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
for f in &files {
|
||||
row(app, ui, t, f, busy);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// What the servers are being asked, and which couldn't say.
|
||||
fn status(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
if app.session.waiting_for(FILES) {
|
||||
return widgets::label(ui, tr("Checking the server…"), t.text_secondary);
|
||||
}
|
||||
if app.session.waiting_for(MANAGE) {
|
||||
return widgets::label(ui, tr("Asking the server…"), t.text_secondary);
|
||||
}
|
||||
let problems: Vec<(String, bool)> =
|
||||
app.session.live.servers.iter().map(|(s, p)| (server_host(s).to_string(), matches!(p, ServerProblem::Offline(_)))).collect();
|
||||
let mut again = false;
|
||||
for (host, offline) in &problems {
|
||||
if *offline {
|
||||
ui.horizontal(|ui| {
|
||||
widgets::label(ui, &trf("Couldn't reach {server}: showing what this computer remembers.", &[("server", host)]), t.danger);
|
||||
again |= widgets::button(ui, tr("Try again"), false, true).clicked();
|
||||
});
|
||||
} else {
|
||||
widgets::label(
|
||||
ui,
|
||||
&trf("{server} can't say what became of your files (it is older than this app).", &[("server", host)]),
|
||||
t.text_secondary,
|
||||
);
|
||||
}
|
||||
}
|
||||
if again {
|
||||
refresh(app, &ui.ctx().clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// One file: its mark, name and details; a click opens it, the button at its end has its menu.
|
||||
fn row(app: &mut FigApp, ui: &mut Ui, t: &Tokens, f: &OnlineFile, busy: bool) {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::click());
|
||||
let gone = f.state == FileState::Gone;
|
||||
let more = Rect::from_center_size(pos2(r.max.x - 18.0, r.center().y), vec2(24.0, 24.0));
|
||||
let details = detail(f);
|
||||
a11y::button(&resp, &format!("{}, {details}", f.name));
|
||||
let ink = if gone { t.text_tertiary } else { t.text };
|
||||
let glyph = if gone { Icon::Close } else { mark(f.shared.is_some()) };
|
||||
let tint = if f.shared.is_some() && !gone { t.success } else { t.icon_secondary };
|
||||
let shown = FileRow { mark: Some((glyph, tint)), name: &f.name, ink, detail: &details, detail_size: 11.0, detail_ink: t.text_secondary };
|
||||
file_row(ui, t, r.with_max_x(more.min.x - 4.0), &resp, &shown);
|
||||
// The menu button takes its own clicks.
|
||||
let mresp =
|
||||
ui.interact(more, ui.id().with(("online_more", &f.server, &f.file)), Sense::click()).tip(trf("Actions for {name}", &[("name", &f.name)]));
|
||||
a11y::menu_button(&mresp, &trf("Actions for {name}", &[("name", &f.name)]), a11y::menu_open(&mresp));
|
||||
if mresp.hovered() {
|
||||
ui.painter().rect_filled(more, CornerRadius::same(5), t.pressed);
|
||||
}
|
||||
icons::paint(ui.painter(), more.shrink(5.0), Icon::Dots, t.icon);
|
||||
let ctx = ui.ctx().clone();
|
||||
let at = (f.server.as_str(), f.file.as_str());
|
||||
crate::right::menu_popup(&mresp).width(240.0).show(|ui| {
|
||||
// What asks the server waits its turn: one request about a file at a time.
|
||||
let item = |ui: &mut Ui, label: &str, show: bool| show && crate::right::menu_item(ui, label, "");
|
||||
if gone {
|
||||
if item(ui, tr("Clear from this list"), true) {
|
||||
forget(app, f);
|
||||
ui.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if item(ui, tr("Open"), true) {
|
||||
open(app, f);
|
||||
ui.close();
|
||||
}
|
||||
let shared = f.shared.as_ref();
|
||||
if item(ui, if shared.is_some() { tr("Stop sharing") } else { tr("Start sharing") }, !busy) {
|
||||
let said = if shared.is_some() {
|
||||
tr("Sharing stopped: the file stays on the server, open only to you")
|
||||
} else {
|
||||
tr("Shared: copy its code from the file's menu")
|
||||
};
|
||||
manage(app, &ctx, at, if shared.is_some() { "unshare" } else { "share" }, json!({}), Box::new(move |app, _, _| app.toast(said)));
|
||||
ui.close();
|
||||
}
|
||||
if let Some(code) = shared.and_then(|s| s.edit.code.as_deref())
|
||||
&& item(ui, tr("Copy code"), true)
|
||||
{
|
||||
app.services.clipboard_set_text(&figcraft_engine::figcraft_collab::code::grouped(code));
|
||||
app.toast(tr("Join code copied"));
|
||||
ui.close();
|
||||
}
|
||||
if let Some(s) = shared
|
||||
&& item(ui, tr("Copy link"), true)
|
||||
{
|
||||
app.services.clipboard_set_text(&s.edit.link);
|
||||
app.toast(tr("Invite link copied"));
|
||||
ui.close();
|
||||
}
|
||||
widgets::separator(ui);
|
||||
if item(ui, tr("Rename on the server…"), !busy) {
|
||||
app.ui.online.rename = Some((f.server.clone(), f.file.clone(), f.name.clone()));
|
||||
app.ui.online.focus_rename = true;
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, tr("Remove from server…"), !busy) {
|
||||
app.ui.online.remove = Some((f.server.clone(), f.file.clone(), f.name.clone()));
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, tr("Forget on this computer"), true) {
|
||||
forget(app, f);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if resp.clicked() && !gone {
|
||||
open(app, f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop `f` from the list on this computer, and say that the file itself stays.
|
||||
fn forget(app: &mut FigApp, f: &OnlineFile) {
|
||||
if app.run("collab.forgetFile", json!({"server": f.server, "file": f.file})).is_none() {
|
||||
return;
|
||||
}
|
||||
if f.state == FileState::Gone {
|
||||
app.toast(trf("{name} cleared from the list", &[("name", &f.name)]));
|
||||
} else {
|
||||
app.toast(trf("{name} is forgotten on this computer; the file stays on {server}", &[("name", &f.name), ("server", &server_host(&f.server))]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape closes the section's dialogs; true when it did.
|
||||
pub fn dialog_keys(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let o = &mut app.ui.online;
|
||||
if (o.remove.is_some() || o.rename.is_some()) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
|
||||
o.remove = None;
|
||||
o.rename = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The confirmation of Remove from server…, and Rename on the server….
|
||||
pub fn dialogs(app: &mut FigApp, ctx: &egui::Context) {
|
||||
dialog_keys(app, ctx);
|
||||
let t = Tokens::get(ctx);
|
||||
if let Some((server, file, name)) = app.ui.online.remove.take() {
|
||||
let (mut open, mut cancel, mut go) = (true, false, false);
|
||||
crate::dialogs::window(tr("Remove from server"), ctx).id(egui::Id::new("Remove online file")).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_width(380.0);
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
|
||||
widgets::label(ui, &trf("Remove {name} from {server}?", &[("name", &name), ("server", &server_host(&server))]), t.text);
|
||||
widgets::label(
|
||||
ui,
|
||||
tr("The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them."),
|
||||
t.danger,
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
go = widgets::button(ui, tr("Remove"), true, true).clicked();
|
||||
cancel = widgets::button(ui, tr("Cancel"), false, true).clicked();
|
||||
});
|
||||
});
|
||||
if go {
|
||||
let said = trf("{name} was removed from the server", &[("name", &name)]);
|
||||
manage(app, ctx, (&server, &file), "remove", json!({}), Box::new(move |app, _, _| app.toast(said)));
|
||||
} else if open && !cancel {
|
||||
app.ui.online.remove = Some((server, file, name));
|
||||
}
|
||||
}
|
||||
if let Some((server, file, mut name)) = app.ui.online.rename.take() {
|
||||
let (mut open, mut cancel, mut go) = (true, false, false);
|
||||
let focus = std::mem::take(&mut app.ui.online.focus_rename);
|
||||
crate::dialogs::window(tr("Rename on the server"), ctx).id(egui::Id::new("Rename online file")).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_width(380.0);
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
|
||||
widgets::label(ui, tr("The name in your online files and on the server. The document inside keeps its own name."), t.text_secondary);
|
||||
let resp = widgets::text_input(ui, egui::Id::new("online_rename"), &mut name, 380.0, tr("File name"));
|
||||
a11y::text(&resp, tr("File name"), &name);
|
||||
if focus {
|
||||
resp.request_focus();
|
||||
}
|
||||
go = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
go |= widgets::button(ui, tr("Rename"), true, !name.trim().is_empty()).clicked();
|
||||
cancel = widgets::button(ui, tr("Cancel"), false, true).clicked();
|
||||
});
|
||||
});
|
||||
if go && !name.trim().is_empty() {
|
||||
manage(app, ctx, (&server, &file), "rename", json!({"name": name.trim()}), Box::new(|_, _, _| {}));
|
||||
} else if open && !cancel {
|
||||
app.ui.online.rename = Some((server, file, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The section's state for `ui.inspect`: no token, no link.
|
||||
pub fn inspect(app: &FigApp) -> Value {
|
||||
let files: Vec<Value> = app
|
||||
.session
|
||||
.prefs
|
||||
.online_files
|
||||
.iter()
|
||||
.map(|f| json!({"file": f.file, "name": f.name, "detail": detail(f), "shared": f.shared.is_some(), "state": f.state, "hasOwnerToken": true}))
|
||||
.collect();
|
||||
json!({
|
||||
"open": app.ui.online.open,
|
||||
"files": files,
|
||||
"checking": app.session.waiting_for(FILES),
|
||||
"problems": app.session.live.servers.len(),
|
||||
"confirmRemove": app.ui.online.remove.as_ref().map(|r| &r.2),
|
||||
"rename": app.ui.online.rename.as_ref().map(|r| &r.2),
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,423 @@
|
|||
//! Home ▸ Online in the window: nothing is asked of a server until the section opens, then each
|
||||
//! server once; the rows (name, where, shared or not, when opened); a server that can't be
|
||||
//! reached; opening a file as its owner (its tab shows when it arrives); the row menu (stop and
|
||||
//! start sharing, copy the code and the link, rename, remove with its confirmation, forget); a
|
||||
//! file that is gone; the marks on tabs of files in a live session; toasts that name a file that
|
||||
//! isn't in front; and an idle section asking for no frames.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use egui::{Event, Key, Shape, pos2};
|
||||
use figcraft_engine::Prefs;
|
||||
use figcraft_engine::figcraft_collab::message::{Admin, AdminReply, ClientMsg, OwnedFile};
|
||||
use figcraft_engine::live::{
|
||||
Access, Event as LiveEvent, HostInfo, HostOptions, LiveLink, NetReply, NetRequest, Peer, Presence, PublishOptions, Role, SessionInfo,
|
||||
Shared as SharedLinks, SharedLink,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::live_tests::{Harness, has_text, text_center, texts};
|
||||
|
||||
const SERVER: &str = "wss://share.example/";
|
||||
const OTHER: &str = "ws://127.0.0.1:47900/";
|
||||
const OWNER: &str = "OwnerSecretOwnerSecret";
|
||||
const EDIT_TOKEN: &str = "EditSecretEditSecret00";
|
||||
|
||||
/// What the scripted servers keep and were asked, and the events the link hands out next.
|
||||
#[derive(Default)]
|
||||
struct Script {
|
||||
hosted: usize,
|
||||
joined: Vec<String>,
|
||||
/// Hosting requests: the server and what was asked.
|
||||
asked: Vec<(String, Option<Admin>)>,
|
||||
/// Share requests: (file, owner, revoke, create).
|
||||
shares: Vec<(String, String, Vec<String>, bool)>,
|
||||
/// Ids of the files on the servers.
|
||||
files: Vec<String>,
|
||||
offline: bool,
|
||||
answers: Vec<LiveEvent>,
|
||||
events: Vec<LiveEvent>,
|
||||
session: u64,
|
||||
copied: Vec<String>,
|
||||
}
|
||||
|
||||
type Shared = Arc<Mutex<Script>>;
|
||||
|
||||
struct Link(Shared);
|
||||
|
||||
fn info(role: Role, name: &str) -> SessionInfo {
|
||||
SessionInfo {
|
||||
role,
|
||||
me: 1,
|
||||
name: "Ada".into(),
|
||||
color: "#14AE5C".into(),
|
||||
file_name: name.into(),
|
||||
pages: vec![],
|
||||
link: "figcraft://join?host=192.168.1.20:47900&file=f&token=AbCdEfGhIjKlMnOpQrStUv".into(),
|
||||
url: String::new(),
|
||||
address: "192.168.1.20:47900".into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
}
|
||||
}
|
||||
|
||||
/// The links a server makes for others the `n`th time a file is shared.
|
||||
fn links(file: &str, n: usize) -> SharedLinks {
|
||||
let link = |what: &str, code: String| SharedLink {
|
||||
id: format!("{what}{n}"),
|
||||
link: format!("figcraft://join?server={SERVER}&file={file}&token=Shared{what}Shared{what}0{n}"),
|
||||
code: Some(code),
|
||||
};
|
||||
SharedLinks { edit: link("edit", format!("NEWED1T0{n}")), view: Some(link("view", format!("NEWV1EW0{n}"))) }
|
||||
}
|
||||
|
||||
impl LiveLink for Link {
|
||||
fn host(&mut self, session: u64, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
s.hosted += 1;
|
||||
Ok(info(Role::Host, "Untitled"))
|
||||
}
|
||||
fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
s.joined.push(link.to_string());
|
||||
Ok(())
|
||||
}
|
||||
fn publish(&mut self, _: u64, _: PublishOptions) -> Result<(), String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn hosting(&mut self, server: &str, _: &str, _: &str, req: Option<Admin>) -> Result<(HostInfo, Option<AdminReply>), String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.asked.push((server.to_string(), req.clone()));
|
||||
if s.offline {
|
||||
return Err("no answer from the server (timed out)".into());
|
||||
}
|
||||
let reply = match req {
|
||||
Some(Admin::Owned { files }) => {
|
||||
let known = |(file, _): &(String, String)| {
|
||||
s.files.contains(file).then(|| OwnedFile {
|
||||
name: format!("File {file}"),
|
||||
links: vec!["edit0".into(), "view0".into()],
|
||||
expires: None,
|
||||
})
|
||||
};
|
||||
AdminReply::Owned(files.iter().map(known).collect())
|
||||
}
|
||||
Some(Admin::Remove { file }) => {
|
||||
s.files.retain(|f| *f != file);
|
||||
AdminReply::Done
|
||||
}
|
||||
_ => AdminReply::Done,
|
||||
};
|
||||
Ok((HostInfo::default(), Some(reply)))
|
||||
}
|
||||
fn share(&mut self, _: &str, file: &str, owner: &str, revoke: &[String], create: bool) -> Result<Option<SharedLinks>, String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.shares.push((file.into(), owner.into(), revoke.to_vec(), create));
|
||||
let n = s.shares.iter().filter(|a| a.3).count();
|
||||
Ok(create.then(|| links(file, n)))
|
||||
}
|
||||
fn background(&mut self, job: u64, req: NetRequest) -> bool {
|
||||
// Answered at the next poll, as the network would.
|
||||
let reply: NetReply = self.request(req);
|
||||
self.0.lock().unwrap().answers.push(LiveEvent::Net { job, reply: Box::new(reply) });
|
||||
true
|
||||
}
|
||||
fn leave(&mut self, _: u64) {}
|
||||
fn presence(&mut self, _: u64, _: &Presence) {}
|
||||
fn send(&mut self, _: u64, _: ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<(u64, LiveEvent)> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
let session = s.session;
|
||||
let answers: Vec<(u64, LiveEvent)> = std::mem::take(&mut s.answers).into_iter().map(|e| (0, e)).collect();
|
||||
answers.into_iter().chain(std::mem::take(&mut s.events).into_iter().map(|e| (session, e))).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The clipboard, kept where the test can read it.
|
||||
struct Clipboard(Shared);
|
||||
|
||||
impl crate::Services for Clipboard {
|
||||
fn clipboard_set_text(&mut self, s: &str) {
|
||||
self.0.lock().unwrap().copied.push(s.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_link(server: &str, file: &str) -> String {
|
||||
format!("figcraft://join?server={server}&file={file}&token={EDIT_TOKEN}")
|
||||
}
|
||||
|
||||
/// A record as the preferences file holds it: `File <id>`, shared with a code or not.
|
||||
fn record(server: &str, file: &str, shared: bool) -> Value {
|
||||
let mut r = json!({"server": server, "file": file, "name": format!("File {file}"), "owner": OWNER, "edit": edit_link(server, file), "created": 1_791_800_000u64, "opened": 1_791_800_000u64});
|
||||
if shared {
|
||||
r["shared"] = serde_json::to_value(links(file, 0)).unwrap();
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A window on the home screen (Recents), with these records loaded from the preferences and all
|
||||
/// of them on their servers.
|
||||
fn harness(records: Vec<Value>) -> (Harness, Shared) {
|
||||
let (mut h, _) = Harness::new();
|
||||
let script = Shared::default();
|
||||
script.lock().unwrap().files = records.iter().filter_map(|r| r["file"].as_str().map(str::to_string)).collect();
|
||||
h.app.session.live.install(Box::new(Link(script.clone())));
|
||||
h.app.services = Box::new(Clipboard(script.clone()));
|
||||
h.app.session.prefs = serde_json::from_value::<Prefs>(json!({"online_files": records})).unwrap();
|
||||
h.app.ui.home = true;
|
||||
h.frames(3);
|
||||
(h, script)
|
||||
}
|
||||
|
||||
/// Open the Online section from the sidebar; what it shows once the servers answered.
|
||||
fn open(h: &mut Harness) -> Vec<Shape> {
|
||||
let shapes = h.frames(1);
|
||||
h.click(text_center(&shapes, "Online"));
|
||||
h.frames(4)
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness, shapes: &[Shape], text: &str) -> Vec<Shape> {
|
||||
h.click(text_center(shapes, text));
|
||||
h.frames(4)
|
||||
}
|
||||
|
||||
/// Open the menu of the row of the file called `name`.
|
||||
fn menu(h: &mut Harness, name: &str) -> Vec<Shape> {
|
||||
let shapes = h.frames(1);
|
||||
// The button at the row's end (the list is 720 wide from x = 280).
|
||||
h.click(pos2(280.0 + 720.0 - 18.0, text_center(&shapes, name).y));
|
||||
h.frames(3)
|
||||
}
|
||||
|
||||
fn toast(h: &Harness) -> String {
|
||||
h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn owned_requests(script: &Shared) -> Vec<(String, usize)> {
|
||||
let s = script.lock().unwrap();
|
||||
s.asked
|
||||
.iter()
|
||||
.filter_map(|(server, req)| if let Some(Admin::Owned { files }) = req { Some((server.clone(), files.len())) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_asked_until_the_section_opens_then_each_server_once() {
|
||||
let (mut h, script) = harness(vec![record(SERVER, "a", true), record(OTHER, "b", false), record(SERVER, "c", false)]);
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "Recents") && has_text(&shapes, "Online") && !has_text(&shapes, "Online files"));
|
||||
assert!(script.lock().unwrap().asked.is_empty(), "the home screen alone asks nothing");
|
||||
let shapes = open(&mut h);
|
||||
assert!(has_text(&shapes, "Online files") && !has_text(&shapes, "Open files"));
|
||||
assert_eq!(owned_requests(&script), [(SERVER.to_string(), 2), (OTHER.to_string(), 1)]);
|
||||
for t in ["File a", "File b", "File c", "share.example · Shared · Opened 2026-10-12", "127.0.0.1:47900 · Not shared · Opened 2026-10-12"] {
|
||||
assert!(has_text(&shapes, t), "the list shows {t:?}: {:?}", texts(&shapes).iter().map(|t| t.0.clone()).collect::<Vec<_>>());
|
||||
}
|
||||
let inspect = h.app.inspect();
|
||||
assert_eq!(
|
||||
(&inspect["online"]["open"], &inspect["online"]["files"][0]["state"], &inspect["online"]["files"][0]["hasOwnerToken"]),
|
||||
(&json!(true), &json!("online"), &json!(true))
|
||||
);
|
||||
for secret in [OWNER, EDIT_TOKEN] {
|
||||
assert!(!inspect.to_string().contains(secret), "ui.inspect carries a secret");
|
||||
}
|
||||
// Staying there asks nothing more, and an idle section asks for no frames.
|
||||
assert!(h.goes_quiet());
|
||||
let (_, delay) = h.frame(vec![]);
|
||||
assert!(delay >= Duration::from_secs(1));
|
||||
assert_eq!(script.lock().unwrap().asked.len(), 2);
|
||||
// Leaving and coming back asks again, once.
|
||||
let shapes = h.frames(1);
|
||||
let shapes = click(&mut h, &shapes, "Recents");
|
||||
assert!(has_text(&shapes, "Open files"));
|
||||
open(&mut h);
|
||||
assert_eq!(script.lock().unwrap().asked.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_list_says_how_files_get_there_and_asks_no_server() {
|
||||
let (mut h, script) = harness(vec![]);
|
||||
let shapes = open(&mut h);
|
||||
assert!(has_text(&shapes, "Nothing online yet. Use Share on a file to put it online and it is listed here."));
|
||||
assert!(script.lock().unwrap().asked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_cant_be_reached_says_so_and_the_list_still_shows() {
|
||||
let (mut h, script) = harness(vec![record(SERVER, "a", true)]);
|
||||
script.lock().unwrap().offline = true;
|
||||
let shapes = open(&mut h);
|
||||
assert!(has_text(&shapes, "Couldn't reach share.example: showing what this computer remembers."));
|
||||
assert!(has_text(&shapes, "File a") && has_text(&shapes, "Try again"));
|
||||
assert_eq!(h.app.inspect()["online"]["files"][0]["state"], "unknown");
|
||||
// Back: Try again asks once more and the note goes.
|
||||
script.lock().unwrap().offline = false;
|
||||
let shapes = click(&mut h, &shapes, "Try again");
|
||||
assert!(!has_text(&shapes, "Try again") && has_text(&shapes, "File a"));
|
||||
assert_eq!(owned_requests(&script).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_opens_its_file_as_owner_and_its_tab_shows_when_it_arrives() {
|
||||
let (mut h, script) = harness(vec![record(SERVER, "a", true)]);
|
||||
let shapes = open(&mut h);
|
||||
let shapes = click(&mut h, &shapes, "File a");
|
||||
assert_eq!(script.lock().unwrap().joined, [edit_link(SERVER, "a")]);
|
||||
assert!(has_text(&shapes, "Joining…") && h.app.ui.home, "still on the home screen while it joins");
|
||||
// The owner's welcome and the document.
|
||||
use figcraft_engine::figcraft_collab::message::ServerMsg;
|
||||
use figcraft_engine::figcraft_collab::{Client, Limits, Outgoing, Server};
|
||||
let mut server = Server::new(h.app.session.doc().unwrap().clone(), "", Limits::default());
|
||||
let ClientMsg::Hello(me) = Client::hello("f", "", "Ada", "") else { panic!("no hello") };
|
||||
let (_, outs) = server.join(me).unwrap();
|
||||
let w = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap();
|
||||
script.lock().unwrap().events.extend([LiveEvent::Welcome { info: info(Role::Guest, "File a"), peers: vec![] }, LiveEvent::Sync(w)]);
|
||||
h.frames(3);
|
||||
assert!(!h.app.ui.home && h.app.session.active == 1, "its tab is in front");
|
||||
assert_eq!(h.app.inspect()["tabs"], json!([{"name": "Untitled", "live": null}, {"name": "Untitled", "live": "shared"}]));
|
||||
// Clicking the row again brings that tab back instead of joining twice.
|
||||
h.app.ui.home = true;
|
||||
let shapes = h.frames(2);
|
||||
click(&mut h, &shapes, "File a");
|
||||
assert!(!h.app.ui.home && script.lock().unwrap().joined.len() == 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_row_menu_stops_and_starts_sharing_and_copies_the_code_and_link() {
|
||||
let (mut h, script) = harness(vec![record(SERVER, "a", true)]);
|
||||
open(&mut h);
|
||||
let shapes = menu(&mut h, "File a");
|
||||
for t in ["Open", "Stop sharing", "Copy code", "Copy link", "Rename on the server…", "Remove from server…", "Forget on this computer"] {
|
||||
assert!(has_text(&shapes, t), "the menu has {t:?}");
|
||||
}
|
||||
click(&mut h, &shapes, "Copy code");
|
||||
assert_eq!(script.lock().unwrap().copied, ["NEW-ED1-T00"]);
|
||||
assert_eq!(toast(&h), "Join code copied");
|
||||
let shapes = menu(&mut h, "File a");
|
||||
click(&mut h, &shapes, "Copy link");
|
||||
assert_eq!(script.lock().unwrap().copied.last(), Some(&links("a", 0).edit.link));
|
||||
// Stop: the others' links are revoked with the recorded token; the row says so.
|
||||
let shapes = menu(&mut h, "File a");
|
||||
let shapes = click(&mut h, &shapes, "Stop sharing");
|
||||
assert_eq!(script.lock().unwrap().shares, [("a".to_string(), OWNER.to_string(), vec!["edit0".to_string(), "view0".to_string()], false)]);
|
||||
assert!(has_text(&shapes, "share.example · Not shared · Opened 2026-10-12"));
|
||||
assert_eq!(toast(&h), "Sharing stopped: the file stays on the server, open only to you");
|
||||
// Not shared: nothing to copy; Start sharing makes a new code.
|
||||
let shapes = menu(&mut h, "File a");
|
||||
assert!(has_text(&shapes, "Start sharing") && !has_text(&shapes, "Copy code") && !has_text(&shapes, "Copy link"));
|
||||
let shapes = click(&mut h, &shapes, "Start sharing");
|
||||
assert!(has_text(&shapes, "share.example · Shared · Opened 2026-10-12"));
|
||||
let shapes = menu(&mut h, "File a");
|
||||
click(&mut h, &shapes, "Copy code");
|
||||
assert_eq!(script.lock().unwrap().copied.last().map(String::as_str), Some("NEW-ED1-T01"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_from_server_asks_first_and_forget_only_drops_the_row() {
|
||||
let (mut h, script) = harness(vec![record(SERVER, "a", true), record(SERVER, "b", false)]);
|
||||
open(&mut h);
|
||||
let shapes = menu(&mut h, "File a");
|
||||
let shapes = click(&mut h, &shapes, "Remove from server…");
|
||||
assert!(has_text(&shapes, "Remove File a from share.example?"));
|
||||
assert!(has_text(
|
||||
&shapes,
|
||||
"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them."
|
||||
));
|
||||
assert_eq!(h.app.inspect()["online"]["confirmRemove"], "File a");
|
||||
// Cancel: nothing happened.
|
||||
let shapes = click(&mut h, &shapes, "Cancel");
|
||||
assert!(!has_text(&shapes, "Remove File a from share.example?") && script.lock().unwrap().files.len() == 2);
|
||||
// Escape closes it too.
|
||||
let shapes = menu(&mut h, "File a");
|
||||
click(&mut h, &shapes, "Remove from server…");
|
||||
for pressed in [true, false] {
|
||||
h.frame(vec![Event::Key { key: Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }]);
|
||||
}
|
||||
assert!(h.app.inspect()["online"]["confirmRemove"].is_null());
|
||||
// Remove: off the server and out of the list.
|
||||
let shapes = menu(&mut h, "File a");
|
||||
let shapes = click(&mut h, &shapes, "Remove from server…");
|
||||
let shapes = click(&mut h, &shapes, "Remove");
|
||||
assert_eq!(script.lock().unwrap().files, ["b"]);
|
||||
assert!(!has_text(&shapes, "File a") && has_text(&shapes, "File b"));
|
||||
assert_eq!(toast(&h), "File a was removed from the server");
|
||||
// Forget: the row goes, the server isn't asked, and the toast says the file stays.
|
||||
let before = script.lock().unwrap().asked.len();
|
||||
let shapes = menu(&mut h, "File b");
|
||||
let shapes = click(&mut h, &shapes, "Forget on this computer");
|
||||
assert!(!has_text(&shapes, "File b") && h.app.session.prefs.online_files.is_empty());
|
||||
assert_eq!(script.lock().unwrap().asked.len(), before);
|
||||
assert_eq!(script.lock().unwrap().files, ["b"], "the file stays on the server");
|
||||
assert_eq!(toast(&h), "File b is forgotten on this computer; the file stays on share.example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_on_the_server_takes_a_name() {
|
||||
let (mut h, script) = harness(vec![record(SERVER, "a", false)]);
|
||||
open(&mut h);
|
||||
let shapes = menu(&mut h, "File a");
|
||||
let shapes = click(&mut h, &shapes, "Rename on the server…");
|
||||
assert!(has_text(&shapes, "Rename on the server") && has_text(&shapes, "Rename"));
|
||||
// The field has the keyboard: select everything in it and type over it.
|
||||
for pressed in [true, false] {
|
||||
h.frame(vec![Event::Key { key: Key::A, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND }]);
|
||||
}
|
||||
h.frame(vec![Event::Text("Landing".into())]);
|
||||
let shapes = h.frames(2);
|
||||
let shapes = click(&mut h, &shapes, "Rename");
|
||||
assert!(script.lock().unwrap().asked.iter().any(|(_, r)| matches!(r, Some(Admin::Rename { file, name }) if file == "a" && name == "Landing")));
|
||||
assert!(has_text(&shapes, "Landing") && h.app.inspect()["online"]["rename"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_gone_says_so_and_can_only_be_cleared() {
|
||||
let (mut h, script) = harness(vec![record(SERVER, "a", true), record(SERVER, "b", false)]);
|
||||
script.lock().unwrap().files = vec!["b".into()];
|
||||
let shapes = open(&mut h);
|
||||
assert!(has_text(&shapes, "share.example · No longer on the server"));
|
||||
// A click doesn't try to open it.
|
||||
click(&mut h, &shapes, "File a");
|
||||
assert!(script.lock().unwrap().joined.is_empty());
|
||||
let shapes = menu(&mut h, "File a");
|
||||
assert!(has_text(&shapes, "Clear from this list") && !has_text(&shapes, "Open") && !has_text(&shapes, "Remove from server…"));
|
||||
let shapes = click(&mut h, &shapes, "Clear from this list");
|
||||
assert!(!has_text(&shapes, "File a") && has_text(&shapes, "File b"));
|
||||
assert_eq!(toast(&h), "File a cleared from the list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_mark_files_in_a_live_session_and_toasts_name_a_file_that_isnt_in_front() {
|
||||
let (mut h, script) = harness(vec![]);
|
||||
h.app.ui.home = false;
|
||||
h.app.session.execute("file.rename", &json!({"name": "Homepage"})).unwrap();
|
||||
assert_eq!(h.app.inspect()["tabs"], json!([{"name": "Homepage", "live": null}]));
|
||||
// Hosting on the local network: others get in.
|
||||
h.app.session.execute("collab.share", &json!({"via": "local"})).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(script.lock().unwrap().hosted, 1);
|
||||
assert_eq!(crate::tabs::live_mark(&h.app, h.app.session.docs[0].uid), Some(true));
|
||||
// A file of yours on a server that nobody else gets into now: marked apart.
|
||||
if let Some(sh) = h.app.session.live_mut().and_then(|l| l.share.as_mut()) {
|
||||
sh.links = None;
|
||||
}
|
||||
h.frames(1);
|
||||
assert_eq!(h.app.inspect()["tabs"], json!([{"name": "Homepage", "live": "private"}]));
|
||||
h.app.session.execute("collab.leave", &json!({})).unwrap();
|
||||
h.app.session.execute("collab.share", &json!({"via": "local"})).unwrap();
|
||||
// Someone joins while the file is in front, then while another is.
|
||||
let peer = |id, name: &str| Peer { client_id: id, name: name.into(), color: "#9747FF".into() };
|
||||
script.lock().unwrap().events.push(LiveEvent::PeerJoined(peer(2, "Hana")));
|
||||
h.frames(2);
|
||||
assert_eq!(toast(&h), "Hana joined");
|
||||
h.app.session.execute("file.new", &json!({})).unwrap();
|
||||
script.lock().unwrap().events.push(LiveEvent::PeerJoined(peer(3, "Ada")));
|
||||
h.frames(2);
|
||||
assert_eq!(toast(&h), "Ada joined Homepage");
|
||||
assert_eq!(h.app.inspect()["tabs"], json!([{"name": "Homepage", "live": "shared"}, {"name": "Untitled", "live": null}]));
|
||||
script.lock().unwrap().events.push(LiveEvent::PeerLeft(2));
|
||||
h.frames(2);
|
||||
assert_eq!(toast(&h), "Hana left Homepage");
|
||||
}
|
||||
|
|
@ -54,7 +54,13 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
|||
for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| app.shows(d.uid)) {
|
||||
let active = i == app.session.active && !app.ui.home;
|
||||
let name = d.doc.name.clone();
|
||||
let w = (regular(12.0).size * 0.6 * name.chars().count() as f32 + 64.0).clamp(120.0, 220.0);
|
||||
// In a live session: a mark after the name says whether others get in.
|
||||
let live = live_mark(app, d.uid);
|
||||
let mark_w = if live.is_some() { 18.0 } else { 0.0 };
|
||||
// (A file in a session is rarely saved here: its unsaved dot gets room beside the mark.)
|
||||
let dirty = d.is_dirty();
|
||||
let dot_w = if dirty && live.is_some() { 12.0 } else { 0.0 };
|
||||
let w = (regular(12.0).size * 0.6 * name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0);
|
||||
let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT));
|
||||
let resp = ui.interact(r, ui.id().with(("tab", d.uid)), if several { Sense::click_and_drag() } else { Sense::click() });
|
||||
if resp.drag_stopped()
|
||||
|
|
@ -72,7 +78,12 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
|||
}
|
||||
});
|
||||
}
|
||||
crate::a11y::tab(&resp, &name, active);
|
||||
let label = match live {
|
||||
Some(true) => trf("{name}, in a live session", &[("name", &name)]),
|
||||
Some(false) => trf("{name}, online, open only to you", &[("name", &name)]),
|
||||
None => name.clone(),
|
||||
};
|
||||
crate::a11y::tab(&resp, &label, active);
|
||||
if active {
|
||||
ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active);
|
||||
} else if resp.hovered() {
|
||||
|
|
@ -85,15 +96,18 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
|||
crate::theme::ACCENT,
|
||||
);
|
||||
// The unsaved-changes dot follows the name on screen, whichever way the name reads.
|
||||
let dirty = d.is_dirty();
|
||||
let shown = crate::bidi::paint_name(
|
||||
ui.painter(),
|
||||
pos2(r.min.x + 32.0, r.center().y),
|
||||
&name,
|
||||
regular(12.0),
|
||||
t.text,
|
||||
w - if dirty { 72.0 } else { 60.0 },
|
||||
w - mark_w - if dirty { 72.0 } else { 60.0 },
|
||||
);
|
||||
if let Some(shared) = live {
|
||||
let at = Rect::from_center_size(pos2(r.max.x - 38.0, r.center().y), vec2(14.0, 14.0));
|
||||
icons::paint(ui.painter(), at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary });
|
||||
}
|
||||
if dirty {
|
||||
ui.painter().text(pos2(shown.max.x + 4.0, r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text);
|
||||
}
|
||||
|
|
@ -171,6 +185,14 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
|||
});
|
||||
}
|
||||
|
||||
/// What the tab of document `uid` says about its live session: others get in (`true`: a session
|
||||
/// this app hosts or joined, a file shared online), only you do (`false`: a file of yours on a
|
||||
/// server, not shared now), or it has none.
|
||||
pub fn live_mark(app: &FigApp, uid: u64) -> Option<bool> {
|
||||
let live = app.session.live_of(uid);
|
||||
live.shared().filter(|_| live.doc_uid == Some(uid))
|
||||
}
|
||||
|
||||
/// Minimise, maximise/restore and close at the right end of the title bar; returns their width.
|
||||
fn window_buttons(ui: &mut Ui, bar: Rect) -> f32 {
|
||||
const W: f32 = 46.0;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ standalone `figcraft-server` (`apps/figcraft-server`, running it: [`server.md`](
|
|||
many files with persistence and compaction, and share links that let people edit or only view;
|
||||
the app publishes to it, and a file's team library lives there for other files to use. Files are
|
||||
shared with **join codes** (see *Sharing with a join code*): online through a server that names
|
||||
its links with short codes, or on the local network with a code that carries the host's address. Sessions
|
||||
its links with short codes, or on the local network with a code that carries the host's address.
|
||||
A file put on a server stays its owner's: the app records it with its owner token and lists
|
||||
**your online files** (see *Your online files*), which open again as their owner. Sessions
|
||||
are per document: an app hosts, joins and publishes several files at once (see Hosting and
|
||||
joining). The rest is the plan the next implementation PRs follow and update;
|
||||
sections say what is built.
|
||||
|
|
@ -191,6 +193,13 @@ before join codes can't read either message and refuses the connection ("that me
|
|||
read"): the app then says the server doesn't know join codes (joining) or shows the invite links in
|
||||
place of a code (sharing). An older app never sends them and joins by link as before.
|
||||
|
||||
**Owned files** came later within version 2 the same way: `Admin owned` on an open hosting
|
||||
connection asks what became of the files a list of `(file id, owner token)` pairs owns, in one
|
||||
request. It is a new variant at the end of `Admin`, and its answer one at the end of the replies,
|
||||
so everything an older peer reads is unchanged. A server from before it can't read the request and
|
||||
refuses the connection ("that message couldn't be read"): the app then shows the files it has
|
||||
recorded as they are, without their state on the server. An older app never sends it.
|
||||
|
||||
**Encoding (decided):** a binary message is a 1-byte wire version followed by **postcard** (serde),
|
||||
which is compact and needs no schema files. Property values inside are compact JSON text: the
|
||||
document types use tagged and flattened serde forms that only self-describing formats can read
|
||||
|
|
@ -222,6 +231,7 @@ Client → server:
|
|||
| `Plugins {list {query} \| fetch {id, version?} \| publish {bundle} \| unpublish {id, version?}}` | A request on a plugin directory connection; each gets one `Plugins` reply, in order. `bundle` is a plugin bundle's bytes (`figcraft_plugin::bundle`). |
|
||||
| `Resolve {protocol, client_version, code}` | Instead of `Hello`: ask which file a join code opens (the code's nine symbols). Answered with `Resolved`, then the server closes the connection; a code it doesn't know, too many wrong ones from the address, or a session hosted by an app is `Refused` with the reason. |
|
||||
| `Admin {code {file, token}}` | A later managing request (with the server key, or the file's owner token on an open hosting connection): a new join code for the link of `file` whose token this is. A link has one code; asking again replaces it. |
|
||||
| `Admin {owned {files: [[file, owner]]}}` | A later request, on any open hosting connection (also one that named no file when it opened): what became of the files these `(file id, owner token)` pairs own, at most 200 a request. Answered with `Admin owned`. |
|
||||
|
||||
Server → client:
|
||||
|
||||
|
|
@ -242,6 +252,7 @@ Server → client:
|
|||
| `Plugins {ready {publisher} \| list [entry] \| bundle {entry, bundle} \| published entry \| removed [version] \| failed {reason}}` | A plugin directory connection's answer: opened (with the key or not), the latest version of each plugin with the versions kept, a bundle and its entry (its SHA-256 among them), kept, unpublished, or why not. |
|
||||
| `Resolved {file, token}` | The answer to `Resolve`: the file the code opens and its link's token, which the client then joins with in an ordinary `Hello` on a new connection (so reconnects, access and revocation are the link's). |
|
||||
| `Admin {code {code}}` | The answer to `Admin code`: the link's new join code, shown this once (the server keeps only what it needs to recognise it). |
|
||||
| `Admin {owned [{name, links, expires?} \| null]}` | The answer to `Admin owned`, one entry per pair asked about, in order: the file's name on the server, the ids of its share links (the owner's own and the ones made for others) and its expiry; `null` when that token manages no file of that id (removed, expired, never there, or a wrong token: they read the same). |
|
||||
|
||||
`Change` is one of: `Create {id, type, props}`, `Set {id, prop, value}`, `Delete {id}` (deleting a
|
||||
node deletes its subtree; ids are never allocated twice), plus the document-level `SetDoc`, `Put`
|
||||
|
|
@ -544,6 +555,50 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
|||
- **In the browser** a code joins like a link (the page asks the server over its own WebSocket,
|
||||
then opens the session's); sharing needs the desktop app.
|
||||
|
||||
*Built* (your online files; `figcraft_engine::online`, `collab.files`, `collab.openFile`,
|
||||
`collab.forgetFile`, `collab.manageFile`):
|
||||
|
||||
- **What is recorded, and where.** When a file reaches a server without the server's key
|
||||
(`collab.share` online, `collab.publish` on a server open to the public), the server's `hosted`
|
||||
answer carries its owner token, once. The app records the file with the preferences, per user
|
||||
and never in a document: the server's address (without a query), the file's id and name, the
|
||||
owner token, the owner's own edit and view links, when it was put there and last opened here,
|
||||
and the links and codes others join with while it is shared. At most 200 files, most recently
|
||||
opened first; recording another drops the one opened longest ago. The preferences are saved at
|
||||
once when the list changes, so a crash doesn't lose a token. A file published with the server's
|
||||
key has no owner token and is the operator's to manage: it isn't recorded.
|
||||
- **The token is a password.** It is stored on this computer only (the desktop app's preferences
|
||||
file; the browser build keeps no preferences and can't publish), is never logged, and no
|
||||
command result, summary or `ui.inspect` carries it or the owner's own links: `collab.files`
|
||||
says `hasOwnerToken: true`. The record's debug output leaves the secrets out. A session opened
|
||||
from a record holds no token either (`published.owner` is null there): requests that need it
|
||||
read it from the record. What is read back from the preferences file is validated entry by
|
||||
entry (addresses, ids, links, lengths, control characters); junk is dropped without costing
|
||||
the other preferences.
|
||||
- **Opening one again** (`collab.openFile {file, server?}`): the app joins with the owner's edit
|
||||
link in an ordinary `Hello`, so reconnects and sync are a guest's, and the session is marked as
|
||||
the owner's of that server file: `collab.stop` and `collab.share` there stop and start its
|
||||
sharing with the recorded token, exactly as in the session that published it. A file that is
|
||||
open already comes to the front instead. After a crash, the desktop app reopens a recorded file
|
||||
that was open and in step with its server this way instead of from its recovery copy (the copy
|
||||
is kept only as a fallback when the record is gone).
|
||||
- **What became of them** (`collab.files {refresh: true}`): one `OpenHosting` connection per
|
||||
server that names no file, then one `Admin owned` with every file recorded on that server.
|
||||
A file the server answers `null` for shows as gone (removed or expired) and can be cleared; a
|
||||
name changed elsewhere is taken over; share links that are no longer among the file's links
|
||||
mark it as not shared. Nothing is asked until the list is opened (Home ▸ Online) or refreshed
|
||||
by a command. A server that can't be reached, or is older than the request, leaves the records
|
||||
as they are and is reported beside them (`problems`).
|
||||
- **Managing from the list.** `collab.manageFile {file, action}` takes the server and the token
|
||||
from the record when they aren't given: `share` makes new links and codes for others, `unshare`
|
||||
revokes the recorded ones (as `collab.stop` does in a session), `rename` renames the file on the
|
||||
server and in the record, `remove` deletes it from the server, drops the record and ends a
|
||||
session open on it (the copy stays open). `collab.forgetFile` drops the record only: the file
|
||||
stays on its server until it expires, and can't be managed from this computer again.
|
||||
- **Asking opens nothing.** `Admin owned` reads the files' descriptions only: it doesn't count as
|
||||
opening a file, so looking at the list never postpones a file's expiry. Each pair is checked
|
||||
like an owner token on `OpenHosting` (its SHA-256 against the stored hash, in constant time).
|
||||
|
||||
*Built* (document sync between apps):
|
||||
|
||||
- The host's session runs the core's `Server`, seeded with the document it shared; the host's
|
||||
|
|
@ -786,8 +841,9 @@ saved state.
|
|||
## Agents, MCP and the CLI
|
||||
|
||||
Everything stays a command: `collab.share`, `collab.host`, `collab.join`, `collab.publish`,
|
||||
`collab.leave`, `collab.peers`, `collab.follow` are ordinary commands, so the CLI, the control channel and MCP can
|
||||
host, publish and join sessions; an agent can be a participant with its own cursor.
|
||||
`collab.leave`, `collab.peers`, `collab.follow`, `collab.files`, `collab.openFile`,
|
||||
`collab.forgetFile` and `collab.manageFile` are ordinary commands, so the CLI, the control channel and MCP can
|
||||
host, publish and join sessions and manage the files they put online; an agent can be a participant with its own cursor.
|
||||
|
||||
*Built*: the desktop app (control channel) and the MCP server's headless session (`figcraft-cli
|
||||
mcp`) both have the network link installed; `collab.join` returns at once with status
|
||||
|
|
@ -854,7 +910,10 @@ chooses to share on the network; the default is the invite link with a random to
|
|||
everyone; refusals say which. Spoofing `X-Forwarded-For` does nothing unless the connection comes
|
||||
from the trusted proxy.
|
||||
- **Open hosting** gives anyone who reaches the server the right to publish (within those limits),
|
||||
to report a file they are in, and with an owner token to manage that one file. Everything else
|
||||
to report a file they are in, and with an owner token to manage that one file and to ask what
|
||||
became of it (`Admin owned`: a wrong token and a missing file are answered alike, so the request
|
||||
tells nothing about files someone doesn't own; it counts against the address's message limit
|
||||
like any other). Everything else
|
||||
(listing files, other files' links, reports, blocks, the plugin directory's publishing) needs the
|
||||
server key. Files are not end-to-end encrypted: the server reads what it keeps, and so can its
|
||||
operator.
|
||||
|
|
@ -893,6 +952,7 @@ chooses to share on the network; the default is the invite link with a random to
|
|||
5. **Standalone `figcraft-server`** with persistence, compaction, share links and permissions.
|
||||
**Done** (`apps/figcraft-server` on the shared hub; edit and view links, the server key,
|
||||
publishing from the app; see [`server.md`](server.md)), followed by open hosting for a public
|
||||
server (owner tokens, limits per address, idle expiry, reports and blocks) and by sharing with
|
||||
join codes (online through the server, or carrying the host's address on the local network).
|
||||
server (owner tokens, limits per address, idle expiry, reports and blocks), by sharing with
|
||||
join codes (online through the server, or carrying the host's address on the local network)
|
||||
and by your online files (the app remembers what it owns and opens it again as its owner).
|
||||
6. **Accounts**: people signing in, per-person permissions, teams and projects.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
|||
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Left: server archives beyond Linux | 25–55 |
|
||||
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
|
||||
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
|
||||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`, no app UI yet), anyone in a file can report it, and the operator blocks addresses. No accounts, teams or projects | 8–17 |
|
||||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The list is per computer: no accounts, teams or projects | 8–17 |
|
||||
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) |
|
||||
| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — |
|
||||
| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 |
|
||||
|
|
@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner. `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. Files are known per computer, not per person (no accounts): percentages and hours unchanged |
|
||||
| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Usability and hosting rather than a new capability: percentages and hours unchanged |
|
||||
| 2026-10-12 | minor | Live sessions are per document (a fix from real use: after hosting or joining in one tab, a new or other file showed that session, and starting one there silently replaced it). Every open file has its own session or none; several run at once and keep syncing behind the tab in front; the Share popover, the people, the cursors, cursor chat, spotlight, follow and the read-only reason are the file in front's (each window its own); the `collab.*` commands act on the active file's session and `collab.peers` lists all of them (`sessions`); joining opens the session's file in its own tab without touching other sessions; closing a file ends its session only. One link carries the sessions, each under its own id (desktop, browser and tests). Percentages and hours unchanged |
|
||||
| 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged |
|
||||
|
|
|
|||
|
|
@ -111,7 +111,14 @@ document can't be edited now (`readOnly`: the "View only" or "Publishing" reason
|
|||
session itself is `engine.execute {command: "collab.peers"}` (sharing with a join code, hosting,
|
||||
joining and publishing are `collab.share`, `collab.host`, `collab.join` and `collab.publish`, opening invite links `collab.registerLinks`,
|
||||
see [mcp.md](mcp.md#live-sessions)). The app also joins the invite link it
|
||||
was started with (`figcraft "figcraft://join?…"`).
|
||||
was started with (`figcraft "figcraft://join?…"`). `online` is the home screen's Online section:
|
||||
whether it shows (`open`), your online files as its rows read (`files: [{file, name, detail,
|
||||
shared, state, hasOwnerToken}]`; never a token or a link of yours), whether the servers are being
|
||||
asked (`checking`), how many couldn't say (`problems`), and the file whose removal is being
|
||||
confirmed or renamed (`confirmRemove`, `rename`; Escape closes them); the list itself is
|
||||
`engine.execute {command: "collab.files"}`. `tabs` lists the open files as the tab strip shows
|
||||
them: `[{name, live}]`, `live` being `"shared"` (in a live session others get into), `"private"`
|
||||
(a file of yours on a server, open only to you) or null.
|
||||
|
||||
### `ui.pointer {kind, x, y | sx, sy, to?, steps?, shift?, alt?, cmd?, ctrl?, pressure?, pressureTo?}`
|
||||
|
||||
|
|
@ -169,10 +176,11 @@ separate calls. Returns `{queued}`.
|
|||
|
||||
Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`.
|
||||
|
||||
### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?}`
|
||||
### `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?}`
|
||||
|
||||
Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`,
|
||||
`home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool, or a top-level menu such
|
||||
`home` (bool), `homeSection` (`recents` or `online`: which section the home screen shows; opening
|
||||
`online` asks each server once what became of your online files), `palette` (bool: the Actions palette), `mainMenu` (bool, or a top-level menu such
|
||||
as `"Plugins"` to open the main menu with it open), `pluginsWindow` (bool: Plugins ▸ Manage
|
||||
plugins…), `pluginsTab` (`installed|browse`: that window's tab), `pluginConfirm` (a plugin id of the
|
||||
directory listed last: its install confirmation shows, as its Install button would; `null` closes
|
||||
|
|
|
|||
|
|
@ -85,7 +85,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
styles, variables and comments that several people create at once all kept; sessions are per
|
||||
document (each open file has its own or none, several at once, closing a file ends only its
|
||||
own); files are shared with a short join code (online through the server in Preferences, or on
|
||||
the local network with a code that carries the host's address) and joined by typing it; a standalone
|
||||
the local network with a code that carries the host's address) and joined by typing it; the app
|
||||
remembers the files it put online and lists them on the home screen (open again as owner, stop
|
||||
and start sharing, rename, remove, forget), per computer; a standalone
|
||||
`figcraft-server` keeps many files (snapshot plus log, compacted; restarts lose nothing
|
||||
confirmed), takes files published from the app and hands out share links that let people edit
|
||||
or only view; the desktop app and the browser reach it over `wss://` through a TLS reverse
|
||||
|
|
@ -390,6 +392,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Gap 2: your online files landed (the app records the files it put on a server with their owner tokens, Home ▸ Online lists them and opens them again as their owner, with sharing, rename, removal and forget from the list; one request per server tells which are still there; tabs mark files in a live session). What is left is unchanged: accounts, teams and per-person permissions, shared comments and versions. Hours unchanged |
|
||||
| 2026-10-12 | minor | Gap 2: sharing with a join code (Share ▸ Online or Local network, one Start sharing button, a short code to give others, one join field for a code or a link; the server names links with codes and limits wrong ones) added to the landed list; usability, so the estimate and ranking are unchanged |
|
||||
| 2026-10-12 | minor | Gap 3: first measured fidelity data. Text box sizes and auto layout results of our example documents were compared with the same documents re-saved by other software; three text rules (whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only) were found and applied, and all 230 self-sizing text boxes in 7 documents now match to the pixel (one font family). Real files are still unchecked: hours and the ranking unchanged |
|
||||
| 2026-10-12 | minor | Gap 2: live sessions are per document (each open file has its own session or none, several run at once, the window and the `collab.*` commands follow the file in front, closing a file ends its session only) added to the landed list; a fix, so the estimate and ranking are unchanged |
|
||||
|
|
|
|||
|
|
@ -78,17 +78,17 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
|
|||
|
||||
| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| English | en | 1,982 / 1,982 (100%, source) | yes | Latin | yes (source) | full | 0 |
|
||||
| Simplified Chinese (Mandarin) | zh-Hans | 1,982 / 1,982 (100%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) |
|
||||
| Spanish | es, es-419 | 1,982 / 1,982 (100%, draft); es-419 overlays 90 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) |
|
||||
| English | en | 2,076 / 2,076 (100%, source) | yes | Latin | yes (source) | full | 0 |
|
||||
| Simplified Chinese (Mandarin) | zh-Hans | 2,076 / 2,076 (100%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) |
|
||||
| Spanish | es, es-419 | 2,076 / 2,076 (100%, draft); es-419 overlays 98 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) |
|
||||
| Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 |
|
||||
| Arabic | ar | 0 (0%) | no | Bidi, shaping and RTL names work; the UI does not mirror | no | none | 9–15 |
|
||||
| French | fr | 1,982 / 1,982 (100%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
|
||||
| Portuguese | pt-BR | 1,982 / 1,982 (100%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
|
||||
| French | fr | 2,076 / 2,076 (100%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
|
||||
| Portuguese | pt-BR | 2,076 / 2,076 (100%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
|
||||
| Indonesian | id | 0 (0%) | no | Latin | no | none | 3–5 |
|
||||
| Japanese | ja | 1,982 / 1,982 (100%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) |
|
||||
| German | de | 1,982 / 1,982 (100%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) |
|
||||
| Korean | ko | 1,982 / 1,982 (100%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) |
|
||||
| Japanese | ja | 2,076 / 2,076 (100%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) |
|
||||
| German | de | 2,076 / 2,076 (100%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) |
|
||||
| Korean | ko | 2,076 / 2,076 (100%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) |
|
||||
| Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 |
|
||||
|
||||
Other languages: none shipped (Figma ships none beyond the eight above).
|
||||
|
|
@ -107,6 +107,7 @@ Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Drafts catch up again, and the counts with them: French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese translate the 71 UI strings and 5 command labels they lacked since sharing with a join code and open hosting (the Share popover, join codes, owner tokens, reports, the .fig export notes), and with Spanish the 26 strings and 3 command labels of your online files (Home ▸ Online, its menu and dialogs, the marks on tabs); machine-drafted in each file's conventions, still drafts. The Latin American overlay adds computadora and agrega where the new Spanish says ordenador and añade (98 entries). 2,076 entries per catalog (1,503 UI strings, 544 command labels, 29 menu wordings) |
|
||||
| 2026-10-12 | minor | Draft catalogs catch up: French, German, Brazilian Portuguese, Japanese and Korean translate the 66 UI strings and 17 command labels added since they were drafted (cursor chat and spotlight, the plugin directory, Rename layers, presentation and snapping keys, list levels, the rotation origin, placing with Enter…), Simplified Chinese the 46 and 12 it lacked; machine-drafted from the English in each file's conventions, still drafts (not in `COMPLETE`). The Latin American overlay adds Control for a gamepad controller and presiona in the plugin directory hint (90 entries, with Enter for the keycap). String counts re-measured: 1,417 UI keys, 536 command labels and 29 menu wordings (1,982 entries), every draft at 100%. Localization grade unchanged |
|
||||
| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h |
|
||||
| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. A regional catalog's lookups (UI strings, command labels, menu wordings) go overlay → base language → English; the check fails on an overlay entry that reads as in its base. Localization 35% → 36%, 32–55 h → 31–54 h |
|
||||
|
|
|
|||
47
docs/mcp.md
47
docs/mcp.md
|
|
@ -927,8 +927,9 @@ only: a host stops hosting it, a guest leaves it.
|
|||
shared: false}`: the others' links and codes are revoked and they are disconnected at once (with
|
||||
their copy), while you stay in the file, which stays on the server, open only to you
|
||||
(`share.shared` is false, `code` null); `collab.share {}` then shares the same file again with
|
||||
new codes, `collab.leave {}` leaves it there, and `collab.manageFile {action: "remove"}` with
|
||||
`published.owner` takes it off the server.
|
||||
new codes, `collab.leave {}` leaves it there, and `collab.manageFile {file, action: "remove"}`
|
||||
takes it off the server (the file is recorded with your online files: see `collab.files`
|
||||
below).
|
||||
- `collab.host {port?, bind?}` starts hosting the active document (a session that document was in
|
||||
ends; other documents' sessions go on): `bind` is `"local"` (this
|
||||
machine, the default), `"lan"` (the local network) or an interface address; the port is 47900,
|
||||
|
|
@ -969,11 +970,43 @@ only: a host stops hosting it, a guest leaves it.
|
|||
server says about its hosting, before publishing (`open`: no key needed; `idleDays`: how long
|
||||
files published without the key are kept unopened, null when they are kept). A server older
|
||||
than open hosting is reported as taking files only with its key.
|
||||
- `collab.manageFile {server, file, owner, action, …}` manages a file published without the key,
|
||||
with its owner token: `links` → `{links: [{id, access, label, created}]}`; `createLink {access?:
|
||||
view|edit, label?}` → `{id, access, link}` (a new share link, shown once); `revoke {link}`
|
||||
(whoever joined with it is disconnected), `rename {name}` (its name on the server) and `remove`
|
||||
(whoever is in it is disconnected) → `{done}`. A wrong token, or another file's, is refused.
|
||||
- `collab.files {refresh?}` → `{files: [{server, host, file, name, created, opened, shared, code,
|
||||
viewCode, state, expires, session, document, hasOwnerToken}], problems: [{server, host, state,
|
||||
reason}], max}`: **your online files**, the files this session (or, attached, the app with its
|
||||
saved preferences) put on a server without the server's key, most recently opened first. `file`
|
||||
is the id the other commands take; `shared` says whether others get in now, with `code` /
|
||||
`viewCode` to hand out; `session` and `document` name the session and open document a file is
|
||||
in, or are null. Without `refresh` nothing is asked of any server and `state` is what was last
|
||||
heard (`"unknown"` until asked). `refresh: true` asks each server once what became of its files:
|
||||
`"online"`, or `"gone"` (removed, or expired unopened); a name changed elsewhere and links
|
||||
revoked elsewhere are taken over. A server that can't be reached, or is older than the request,
|
||||
is listed in `problems` (`state: "offline"` with the `reason`, or `"unsupported"`) and its
|
||||
files stay as they were. **Owner tokens and your own links are never in the result**
|
||||
(`hasOwnerToken: true` stands for the token); they are kept with the preferences. The headless
|
||||
session keeps its list only while it runs: note `published.owner` from `collab.peers` if a file
|
||||
must be managed after it ends.
|
||||
- `collab.openFile {file, server?}` joins one of your online files again as its owner →
|
||||
`{session, status: "connecting", document, alreadyOpen, file, server, name}`; it opens as a new
|
||||
document (poll `collab.peers` until `"live"`), where `collab.stop {}` and `collab.share {}` stop
|
||||
and start its sharing. A file that is open already becomes the active document instead
|
||||
(`alreadyOpen: true`, `document` its index). `server` is needed only when files on several
|
||||
servers share an id. A file whose `state` is `"gone"` is refused.
|
||||
- `collab.forgetFile {file, server?}` → `{forgotten, file, server, onServer}` drops a file from
|
||||
the list **on this computer only**: the file stays on its server (`onServer`) until it expires,
|
||||
and can't be managed from here again. To take it off the server use `collab.manageFile {file,
|
||||
action: "remove"}` instead.
|
||||
- `collab.manageFile {file, action, server?, owner?, …}` manages a file published without the key,
|
||||
with its owner token. `server` and `owner` default to what is recorded for one of your online
|
||||
files (`collab.files`); give both for a file that isn't recorded here. `links` → `{links: [{id,
|
||||
access, label, created}]}`; `createLink {access?: view|edit, label?}` → `{id, access, link}` (a
|
||||
new share link, shown once); `revoke {link}` (whoever joined with it is disconnected); `share`
|
||||
→ `{shared: true, code, viewCode, editLink, viewLink}` (new links and join codes for others; the
|
||||
same ones when it is shared already) and `unshare` → `{shared: false, …}` (the recorded links
|
||||
and codes are revoked and whoever joined with them is disconnected; the file stays yours on the
|
||||
server); `rename {name}` (its name on the server and in your list) and `remove` (the file is
|
||||
deleted from the server, whoever is in it is disconnected, it leaves your list and a session
|
||||
open on it here ends, its document staying open as a copy) → `{done}`. A wrong token, or
|
||||
another file's, is refused.
|
||||
- `collab.report {reason}` (at most 500 characters) reports the file you joined on a server to its
|
||||
operator → `{sent}`; the session's message says when the server kept it, or why not (ten an hour
|
||||
per address). Servers older than this version don't take reports (`hosting` is null).
|
||||
|
|
|
|||
|
|
@ -64,9 +64,11 @@ people you trust.
|
|||
On a server [open to the public](#running-a-public-server) the address alone is enough
|
||||
(`wss://figcraft.example.com/`): the dialog asks the server first and shows that no key is needed,
|
||||
who runs it ("Hosted by …" and a link to its terms) and how long unopened files are kept. Publishing
|
||||
then also gives an **owner token**, shown once with the links in a **File published** window: copy
|
||||
it and keep it, it is the only way to revoke the links, rename the file or remove it later
|
||||
(`collab.manageFile` from [MCP](mcp.md#live-sessions)). The Share popover shows the server's line
|
||||
then also gives an **owner token**, shown once with the links in a **File published** window. The
|
||||
app keeps it with your preferences and lists the file under [Home ▸ Online](#your-online-files),
|
||||
where it can be opened, shared, renamed and removed later without the token in hand; copy it if
|
||||
you want to manage the file from another computer or a script (`collab.manageFile` from
|
||||
[MCP](mcp.md#live-sessions)). The Share popover shows the server's line
|
||||
and the date the server deletes the file unless someone opens it.
|
||||
|
||||
## Sharing with a join code
|
||||
|
|
@ -86,7 +88,8 @@ working at once, whoever joined with them is disconnected ("Sharing was stopped
|
|||
copy they have), and the owner stays in the file, which stays on the server, open only to them.
|
||||
**Start sharing** again gives the same file new links and codes. **Share ▸ Advanced ▸ Remove from
|
||||
server…** (it asks first) takes the file off the server; closing the tab or quitting changes
|
||||
nothing on the server. To use **your** server for this, set its address
|
||||
nothing on the server, and the file is still yours afterwards: see
|
||||
[Your online files](#your-online-files). To use **your** server for this, set its address
|
||||
(`wss://figcraft.example.com/`) in Preferences on every machine that shares or joins; a server
|
||||
that needs its key is reached under **Advanced ▸ Publish to another server…**, or with
|
||||
`collab.share {server: "wss://…/?key=…"}`, and gets codes too.
|
||||
|
|
@ -112,6 +115,54 @@ that needs its key is reached under **Advanced ▸ Publish to another server…*
|
|||
no migration, existing files get codes the next time they are shared, and no setting turns codes
|
||||
on or off. Behind the reverse proxy nothing changes (codes travel inside the WebSocket).
|
||||
|
||||
## Your online files
|
||||
|
||||
A file you put on a server without its key is yours, and stays where it is when its tab closes or
|
||||
the app quits. The app remembers each one **with your preferences on this computer** (never in the
|
||||
document): the server, the file's id and name, its owner token, your own links to it, when you put
|
||||
it there and last opened it, and the code and links others join with while it is shared. The home
|
||||
screen lists them under **Online**:
|
||||
|
||||
- **A row** shows the file's name, the server, **Shared** or **Not shared**, and when you last
|
||||
opened it. Clicking it opens the file as its owner, in step with the server, in a tab of its
|
||||
own (or brings its tab to the front when it is open already). In that tab **Stop sharing** and
|
||||
**Start sharing** work as in the session that first shared it.
|
||||
- **Its menu** (the button at the row's end): **Open**; **Stop sharing** / **Start sharing**
|
||||
(others lose their links and codes, or get new ones; the file itself stays); **Copy code** and
|
||||
**Copy link** while it is shared; **Rename on the server…**; **Remove from server…** (it asks
|
||||
first: the file is deleted there for everyone and can't be brought back); **Forget on this
|
||||
computer** (the row goes, **the file stays on the server** until it expires, and without its
|
||||
token this computer can't manage it again).
|
||||
- **Nothing is asked of a server until you open Online** (or act on a file). Then each server is
|
||||
asked once, in the background, what became of your files there. A file the server no longer has
|
||||
(removed, or expired unopened) reads **No longer on the server** and can be cleared from the
|
||||
list. A server that can't be reached says so above the list, with **Try again**; the rows still
|
||||
show what this computer remembers.
|
||||
- **Tabs** of files in a live session carry a small mark after the name: waves for a session
|
||||
others get into (a file you share or joined), a closed ring for a file of yours on a server that
|
||||
only you get into now. Messages about a file that isn't in front name it ("Ada joined
|
||||
Homepage").
|
||||
- **After a crash**, a file of yours that was open and in step with its server opens again from
|
||||
the server as its owner, rather than as an unsaved recovery copy.
|
||||
|
||||
The owner token is a password for that one file: it is kept only in the preferences file of your
|
||||
user account (`state.json` in FigCraft's settings folder), is never logged, and never appears in
|
||||
what commands return (`collab.files` says `hasOwnerToken: true`). Whoever can read that file can
|
||||
manage your online files; a list of at most 200 files is kept (the one opened longest ago makes
|
||||
room). The files are the ones published **from this computer**: another computer of yours doesn't
|
||||
know them unless you manage them there with the token. A file published with the server's key has
|
||||
no owner token and belongs to the server's operator: it isn't listed.
|
||||
|
||||
Scripts and agents use `collab.files {refresh?}`, `collab.openFile {file}`,
|
||||
`collab.forgetFile {file}` and `collab.manageFile {file, action}` ([MCP](mcp.md#live-sessions)).
|
||||
|
||||
- **Upgrade note.** Asking what became of the files needs a server of this version or newer. An
|
||||
older server still works for everything else: the list shows what the computer remembers, says
|
||||
that the server can't tell what became of the files, and opening, sharing, renaming and removing
|
||||
go on as before (they use requests the server already knows). To get the state of the files,
|
||||
upgrade the server binary and restart it: the data directory needs no migration and there is no
|
||||
setting to turn on.
|
||||
|
||||
## Joining
|
||||
|
||||
A [join code](#sharing-with-a-join-code) is the short way in. Share links look like the desktop
|
||||
|
|
@ -350,7 +401,9 @@ keeps the key for everything else.
|
|||
runs it (`operator_contact`), its terms (`terms_url`) and how long unopened files are kept. A file
|
||||
published without the key gets its edit and view links and an **owner token** (128 random bits,
|
||||
shown once, kept only as its SHA-256): with it, whoever published the file lists, makes and
|
||||
revokes its links, renames it on the server or removes it (`collab.manageFile`), and nothing else.
|
||||
revokes its links, renames it on the server or removes it (`collab.manageFile`), asks whether
|
||||
it is still there (what the app's [Online list](#your-online-files) does, for all its files in
|
||||
one request), and nothing else.
|
||||
An app older than open hosting is told the server is open when it tries a key.
|
||||
- **Limits per address** (only when open): connections at once (`max_connections_per_ip`), new
|
||||
connections a minute (`connects_per_minute`), files published an hour (`publishes_per_hour`),
|
||||
|
|
@ -402,7 +455,7 @@ What someone can do, by what they hold (the server is reached through TLS at the
|
|||
| Someone with a **join code** | Exactly what its link gives (a view code: the view link's row; an edit code: the edit link's), until the code or its link is gone | Get a code for any other link; learn anything from a wrong code but that it is wrong |
|
||||
| Someone with a file's **view link** | See that file and everyone's changes and presence in it; read its team library and usage; report it | Change it (the server refuses a viewer's edits); reach any other file; manage links |
|
||||
| Someone with a file's **edit link** | Change that file, within its size cap; publish its team library (on a public server only for files published with the key); report it | Reach any other file; grow it past `max_file_mb` or its publisher's share; list, make or revoke links; rename or remove it |
|
||||
| Whoever holds a file's **owner token** (published without the key) | List, make and revoke that file's links (disconnecting whoever joined with a revoked one, which is how the app stops sharing), ask for join codes for links whose token they hold, rename it on the server, remove it | Touch any other file, even with its id; list files; read reports; block; anything of the plugin directory |
|
||||
| Whoever holds a file's **owner token** (published without the key) | List, make and revoke that file's links (disconnecting whoever joined with a revoked one, which is how the app stops sharing), ask for join codes for links whose token they hold, rename it on the server, remove it; ask whether it is still there, its name, link ids and expiry (asking doesn't count as opening it) | Touch any other file, even with its id; tell a file that doesn't exist from one they don't own; list files; read reports; block; anything of the plugin directory |
|
||||
| The **operator** (server key, or the machine) | Everything above for every file, reports, blocks, the plugin directory; read every file on disk | Recover a lost link or owner token (only hashes are kept) |
|
||||
|
||||
Limits that hold against everyone: message sizes by what a connection is for, file sizes as they
|
||||
|
|
|
|||
Loading…
Reference in New Issue