diff --git a/PRIVACY.md b/PRIVACY.md
index 4619088..21e05e5 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -37,6 +37,7 @@ What you can choose, and what each implies:
| **A folder your cloud client keeps in step** (Google Drive, OneDrive, iCloud, Dropbox, pCloud…) | That provider, under their terms and their jurisdiction |
| **A folder on hardware you own** (a NAS, an external drive) | Whoever can reach that hardware |
| **A backup zip you keep somewhere** | Wherever you put it |
+| **A `.tistyx` parcel of documents you hand to somebody** | Them, and whoever they hand it to. You decide what goes in it and when |
For syncing, Tisty only ever reads and writes local paths. It has no network
code for it, no credentials, and no idea which provider — if any — is behind the
@@ -66,6 +67,15 @@ visible instead of making it for you.
identifier itself does appear in the shared folder — it names your device's
directory and stamps every event — because that is what tells two writers
apart. What must never be shared is the file that binds it to this machine.
+- **An alias, if you write one** — it exists so that a document you share still
+ says who wrote it. It is optional, it lives in the log like everything else,
+ and so it reaches your other machines through the folder you sync and travels
+ inside any parcel you hand out. It goes out with the sync before you share
+ anything, which is why it should be a name you are content for others to read.
+ A log only ever grows: an alias you write today can be changed tomorrow, but
+ the old one stays written. Your real name and your address are **not** asked
+ for anywhere, and the two fields reserved for them in the log are written by
+ nothing in this version.
- **The time zone you were in** — every event carries the IANA zone of whoever
wrote it, so an hour reads back where it happened instead of drifting when you
travel. It goes to the shared folder with the rest, and a long enough history
diff --git a/README.es.md b/README.es.md
index c2b1364..69a11d1 100644
--- a/README.es.md
+++ b/README.es.md
@@ -402,6 +402,16 @@ columna con de qué va el documento, el formato que el menú `/` escondía y su
índice. **Tisty genera su propio PDF** —A4, Carta o una hoja sin fin, con sus
propios márgenes y los adjuntos dentro— y te lo enseña antes de exportarlo.
+**Y salen enteros.** Un documento se copia como Markdown, se escribe en una
+carpeta con sus páginas y sus adjuntos al lado, o se exporta para Tisty en un
+archivo `.tistyx` que además guarda lo que el Markdown no sabe decir: las
+carpetas con su orden, su icono y su color, de qué documento cuelga cada
+página, qué está archivado y con qué alias se firmó. Así se mudan tus
+documentos a otro equipo o llegan a otra persona que usa Tisty; dentro no viaja
+ni una línea del historial. Si escribes un alias —opcional, y solo tú decides
+cuál—, cada documento queda firmado con él, y lo que te llegue de otra persona
+conserva el suyo.
+

**Un atajo global** abre un campo pequeño encima de lo que estés haciendo, así
diff --git a/README.md b/README.md
index 7caf207..2906124 100644
--- a/README.md
+++ b/README.md
@@ -402,6 +402,16 @@ outline. **Tisty makes its own PDF** — A4, Letter or one endless sheet, with i
own margins and the attachments carried inside — and shows it to you before you
export it.
+**And they leave whole.** A document copies as Markdown, writes out into a
+folder with its pages and attachments beside it, or is exported for Tisty into a
+`.tistyx` file that also carries what Markdown cannot say: folders with their order,
+icon and colour, which document each page hangs from, what is archived, and the
+alias it was signed with. That is how your documents move to another machine
+or reach somebody else who uses Tisty, and not one line of the history travels
+inside it. Write an alias — optional, and yours to choose — and every
+document is signed with it, while whatever arrives from somebody else keeps
+theirs.
+

**A global shortcut** opens a small field over whatever you are doing, so a task
diff --git a/SECURITY.md b/SECURITY.md
index c0b1b59..f3dc465 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -116,6 +116,24 @@ Being explicit here matters more than sounding reassuring.
outright — that removal is absorbing, so a removed identifier is never valid
again and a machine that returns comes back as a new one.
+- **A parcel of documents is a file somebody hands you**, and it is treated as
+ such. Every path inside the zip must be an ordinary relative path under
+ `docs/` or `attachments/` — anything climbing out with `..`, an absolute path
+ or a drive prefix is dropped rather than written. The names written in the
+ manifest go through the same door as any other document name, so a manifest
+ cannot point the reader at a file elsewhere on your disk. The manifest itself
+ is read under a ceiling, and so is the parcel: bytes actually written are
+ counted rather than the sizes the archive declares, and both the number of
+ files and the number of entries in the manifest are capped. A folder name or
+ an alias arriving inside is trimmed to the same limits the window applies, and
+ an icon or a colour it does not recognise is dropped instead of stored.
+
+ What a parcel cannot do is prove who wrote what: anyone can edit the manifest
+ in a zip. Tisty decides what came from elsewhere by the identity of the store
+ that sent it, not by the name written inside, so a parcel never quietly turns
+ somebody else's writing into yours — but a name in a parcel is a claim, not a
+ signature, and nothing here verifies it.
+
## An assistant, if you admit one
Tisty speaks MCP so an assistant already running on your machine can file work
diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs
index 87b7c92..d34eb72 100644
--- a/app/src-tauri/src/lib.rs
+++ b/app/src-tauri/src/lib.rs
@@ -181,7 +181,7 @@ impl Session {
fn retell(&mut self, file: &str, body: &str) -> bool {
let mut told = self.state.settling(file, body);
if let Some(kept) = self.state.docs.values().find(|one| one.file == file) {
- let said = tisty_core::event::Said::of(body);
+ let said = tisty_core::event::Said::of(body).by(signing(&self.state));
if said.news_for(kept) {
told.push(Op::DocSaid {
id: kept.id,
@@ -328,6 +328,10 @@ impl Session {
self.commit(Op::DocAdd {
id: ulid::Ulid::generate(),
d: tisty_core::event::DocAdd {
+ wrote: None,
+ guest: false,
+ made: None,
+ by: signing(&self.state),
file: file.to_string(),
order,
said: Some(tisty_core::event::Said::of(&body)),
@@ -431,6 +435,7 @@ impl Session {
}
fn tidy_up(&mut self, bin: bool) {
+ tisty_core::parcel::swept(self.paths.data());
let dest = self.dest();
tisty_core::tidy::all_of_it(
&self.paths,
@@ -1208,7 +1213,7 @@ fn capture(
draft.filing = Some(tisty_core::capture::Filing::Kept(id));
}
for name in &view.tags {
- if let Ok(tag) = Tag::new(name)
+ if let Ok(tag) = Tag::written(name)
&& !draft.tags.contains(&tag)
{
draft.tags.push(tag);
@@ -1412,7 +1417,7 @@ fn tagged(task: &Task, change: &Change) -> Result>, Refusal> {
tags.retain(|kept| *kept != gone);
}
if let Some(name) = &change.add_tag {
- let one = Tag::new(name).map_err(|_| Refusal::about("badTag", name))?;
+ let one = Tag::written(name).map_err(|_| Refusal::about("badTag", name))?;
if !tags.contains(&one) {
tags.push(one);
}
@@ -2036,6 +2041,12 @@ const REFUSALS: &[&str] = &[
"stillCarrying",
"sandboxCannotMerge",
"noSuchDoc",
+ "notAParcel",
+ "parcelNewer",
+ "nothingToCarry",
+ "stillPacking",
+ "aliasTooLong",
+ "tooBig",
"noSuchIcon",
"noSuchColour",
"noSuchFolder",
@@ -2569,6 +2580,8 @@ struct Filed {
archived: bool,
locked: bool,
gone: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ guest: Option,
page_of: Option,
}
@@ -2614,11 +2627,14 @@ fn docs_catch_up(session: tauri::State<'_, Mutex>) -> Answer
.into_iter()
.filter_map(|(one, said)| {
let kept = held.state.docs.values().find(|kept| kept.file == one.id)?;
- let said = said.unwrap_or_else(|| tisty_core::event::Said {
- title: one.title.clone(),
- bytes: None,
- tags: Some(kept.tags.clone()),
- });
+ let said = said
+ .unwrap_or_else(|| tisty_core::event::Said {
+ title: one.title.clone(),
+ bytes: None,
+ tags: Some(kept.tags.clone()),
+ by: None,
+ })
+ .by(signing(&held.state));
said.news_for(kept).then_some(Op::DocSaid {
id: kept.id,
d: said,
@@ -2660,6 +2676,7 @@ fn gathered(session: &Session) -> Vec {
archived: kept.archived,
locked: session.state.shut(kept.id),
gone: !on_disk.contains(&kept.file),
+ guest: kept.guest.then(|| kept.by.clone()).flatten(),
page_of: kept.page_of.map(|up| up.to_string()),
tags: kept.tags.iter().map(|one| one.to_string()).collect(),
})
@@ -2683,8 +2700,8 @@ fn read_tags(session: tauri::State<'_, Mutex>) -> Answer {
let Ok(body) = tisty_core::docs::read(&root, &file) else {
continue;
};
- let said = tisty_core::event::Said::of(&body);
let session = held(&session);
+ let said = tisty_core::event::Said::of(&body).by(signing(&session.state));
let Some(kept) = session.state.docs.get(&id) else {
continue;
};
@@ -3165,6 +3182,9 @@ struct Facts {
wrote: Option,
bytes: u64,
pages: usize,
+ author: Option,
+ editor: Option,
+ born: Option,
}
fn seconds(at: std::io::Result) -> Option {
@@ -3185,24 +3205,142 @@ fn keep_pdf(at: String, bytes: Vec) -> Answer<()> {
})
}
+#[derive(serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Signed {
+ alias: Option,
+ before: Vec,
+ mine: usize,
+}
+
+#[tauri::command]
+fn signed(session: tauri::State<'_, Mutex>) -> Answer {
+ let session = held(&session);
+ Ok(as_signed(&session))
+}
+
+fn signing(state: &tisty_core::State) -> Option {
+ state.signed.alias.clone()
+}
+
+fn as_signed(session: &Session) -> Signed {
+ Signed {
+ alias: session.state.signed.alias.clone(),
+ before: {
+ let mut seen: Vec = Vec::new();
+ for was in session.state.signed_before.iter().rev() {
+ if !seen
+ .iter()
+ .any(|one| tisty_core::state::same_name(one, was))
+ {
+ seen.push(was.clone());
+ }
+ }
+ seen
+ },
+ mine: match session.state.signed.alias.is_some() {
+ true => session.state.mine_to_sign().len(),
+ false => 0,
+ },
+ }
+}
+
+#[tauri::command]
+fn sign_the_rest(session: tauri::State<'_, Mutex>) -> Answer {
+ let mut session = held(&session);
+ let Some(alias) = session.state.signed.alias.clone() else {
+ return Ok(0);
+ };
+ let ops: Vec = session
+ .state
+ .mine_to_sign()
+ .into_iter()
+ .map(|id| Op::DocSigned {
+ id,
+ d: alias.clone(),
+ })
+ .collect();
+ let many = ops.len();
+ if many > 0 {
+ session
+ .commit_all(ops)
+ .map_err(|e| blamed(channel::WINDOW, "the documents could not be signed", e))?;
+ }
+ Ok(many)
+}
+
+#[tauri::command]
+fn sign(session: tauri::State<'_, Mutex>, alias: Option) -> Answer {
+ let said = alias
+ .map(|one| tisty_core::text::plainly(&one).trim().to_string())
+ .filter(|one| !one.is_empty());
+ if said
+ .as_ref()
+ .is_some_and(|one| one.chars().count() > tisty_core::event::ALIAS_AT_MOST)
+ {
+ return Err(Refusal::about(
+ "aliasTooLong",
+ tisty_core::event::ALIAS_AT_MOST.to_string(),
+ ));
+ }
+
+ let mut session = held(&session);
+ let same = match (said.as_deref(), session.state.signed.alias.as_deref()) {
+ (Some(one), Some(was)) => tisty_core::state::same_name(one, was),
+ (one, was) => one == was,
+ };
+ if same {
+ return Ok(as_signed(&session));
+ }
+ let mut signature = session.state.signed.clone();
+ signature.alias = said;
+ session
+ .commit(Op::Signed {
+ d: signature.clone(),
+ })
+ .map_err(|e| blamed(channel::WINDOW, "the signature could not be written", e))?;
+ Ok(as_signed(&session))
+}
+
#[tauri::command]
fn doc_facts(session: tauri::State<'_, Mutex>, id: String) -> Answer {
let session = held(&session);
let root = session.paths.docs();
let kept = session.state.docs.values().find(|one| one.file == id);
- let made = kept.map(|one| (one.id.timestamp_ms() / 1000) as i64);
+ let made = kept.map(|one| match one.made {
+ Some(at) => at.as_second(),
+ None => (one.id.timestamp_ms() / 1000) as i64,
+ });
let pages = kept.map_or(0, |one| session.state.pages_of(one.id).len());
+ let author = kept
+ .and_then(|one| session.state.author_of(one))
+ .map(str::to_string);
+ let editor = kept
+ .and_then(|one| session.state.editor_of(one))
+ .map(str::to_string);
+ let born = kept
+ .and_then(|one| session.state.born_of(one))
+ .map(str::to_string);
let at = tisty_core::docs::resolve(&root, &id)
.map_err(|_| Refusal::about("noSuchDoc", id.clone()))?;
let about = std::fs::metadata(&at).map_err(|_| Refusal::about("noSuchDoc", id))?;
+ let wrote = kept
+ .and_then(|one| one.wrote)
+ .map(|at| at.as_second())
+ .or_else(|| seconds(about.modified()));
Ok(Facts {
made,
- wrote: seconds(about.modified()),
+ wrote,
bytes: about.len(),
pages,
+ author,
+ editor,
+ born,
})
}
+const WRITTEN_BY: &str = "Tisty";
+
const PICTURES: &[&str] = &[
"captura.png",
"prioridades.png",
@@ -3344,12 +3482,17 @@ fn guide(
session.commit(Op::DocAdd {
id: ulid::Ulid::generate(),
d: tisty_core::event::DocAdd {
+ wrote: None,
+ guest: true,
+ made: None,
+ by: Some(WRITTEN_BY.into()),
file: made.id.clone(),
order: sorted,
said: Some(tisty_core::event::Said {
title: made.title.clone(),
bytes: None,
tags: Some(Vec::new()),
+ by: None,
}),
folder: Some(folder),
page_of: None,
@@ -3371,6 +3514,10 @@ fn guide(
session.commit(Op::DocAdd {
id: ulid::Ulid::generate(),
d: tisty_core::event::DocAdd {
+ wrote: None,
+ guest: true,
+ made: None,
+ by: Some(WRITTEN_BY.into()),
file: file.clone(),
order: order.clone(),
said,
@@ -3400,7 +3547,10 @@ fn doc_write(
) -> Answer {
let mut session = held(&session);
if session.state.bolted(&id) {
- return Err(Refusal::of("documentLocked"));
+ return Err(Refusal::of(match session.state.away(&id) {
+ true => "documentAway",
+ false => "documentLocked",
+ }));
}
if !anyway.unwrap_or(false) && session.moved(&id) {
return Err(Refusal::about("documentMoved", id));
@@ -3432,6 +3582,7 @@ fn noted(session: &mut Session, file: &str, body: &str) {
title: tisty_core::docs::titled(body),
bytes: kept.bytes,
tags: Some(tisty_core::tagging::tags_in(body)),
+ by: None,
};
if !told.news_for(kept) {
return;
@@ -3439,6 +3590,7 @@ fn noted(session: &mut Session, file: &str, body: &str) {
let id = kept.id;
let said = tisty_core::event::Said {
bytes: Some(body.len() as u64),
+ by: signing(&session.state),
..told
};
let _ = session.commit(Op::DocSaid { id, d: said });
@@ -3524,15 +3676,21 @@ fn doc_copy(
.map(|one| one.order.as_str()),
);
let twin = ulid::Ulid::generate();
+ let signed_as = signing(&session.state);
session.commit(Op::DocAdd {
id: twin,
d: tisty_core::event::DocAdd {
+ wrote: None,
+ guest: false,
+ made: None,
+ by: signed_as.clone(),
file: made.id.clone(),
order,
said: Some(tisty_core::event::Said {
title: made.title.clone(),
bytes: None,
tags: Some(kept.tags.clone()),
+ by: None,
}),
folder: kept.folder,
page_of: kept.page_of,
@@ -3563,15 +3721,21 @@ fn doc_copy(
.filter(|one| one.page_of == Some(twin))
.map(|one| one.order.as_str()),
);
+ let signed_as = signing(&session.state);
session.commit(Op::DocAdd {
id: ulid::Ulid::generate(),
d: tisty_core::event::DocAdd {
+ wrote: None,
+ guest: false,
+ made: None,
+ by: signed_as.clone(),
file: leaf.id,
order,
said: Some(tisty_core::event::Said {
title: leaf.title,
bytes: None,
tags: Some(Vec::new()),
+ by: None,
}),
folder: kept.folder,
page_of: Some(twin),
@@ -3643,11 +3807,13 @@ fn doc_export(
.collect()
})
.unwrap_or_default();
+ let beside = session.dest();
tisty_core::docs::with_pages(
session.paths.data(),
&id,
&pages,
std::path::Path::new(&into),
+ beside.as_deref(),
)
.map_err(|e| {
witness::warn(
@@ -3660,9 +3826,22 @@ fn doc_export(
);
Refusal::about("cannotWrite", into)
})
- .map(|took| Taken {
- files: took.files,
- missed: took.missed,
+ .map(|took| {
+ if !took.left.is_empty() {
+ witness::warn(
+ channel::WINDOW,
+ "a document went out without everything it points at",
+ &[
+ ("id", Fact::Id(id.clone())),
+ ("left", Fact::Why(took.left.join("; "))),
+ ],
+ );
+ }
+ Taken {
+ files: took.files,
+ missed: took.missed,
+ left: took.left.len(),
+ }
})
}
@@ -3671,6 +3850,255 @@ fn doc_export(
struct Taken {
files: usize,
missed: usize,
+ left: usize,
+}
+
+#[derive(Clone, serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Afoot {
+ stage: &'static str,
+ far: u64,
+ done: usize,
+ whole: usize,
+}
+
+fn along_the_way(
+ app: &tauri::AppHandle,
+ stage: &'static str,
+) -> impl Fn(tisty_core::parcel::Step) + use<> {
+ let app = app.clone();
+ let said = std::sync::atomic::AtomicU64::new(u64::MAX);
+ move |step| {
+ let far = match step.whole {
+ 0 => 0,
+ whole => (step.done as u64 * 100 / whole as u64).min(100),
+ };
+ if said.swap(far, std::sync::atomic::Ordering::Relaxed) == far {
+ return;
+ }
+ let _ = app.emit(
+ "carrying",
+ Afoot {
+ stage,
+ far,
+ done: step.done,
+ whole: step.whole,
+ },
+ );
+ }
+}
+
+fn standing(
+ session: &tauri::State<'_, Mutex>,
+ which: &[String],
+) -> (
+ std::path::PathBuf,
+ tisty_core::State,
+ Option,
+) {
+ let mut session = held(session);
+ for one in which {
+ if let Ok(body) = tisty_core::docs::read(&session.paths.docs(), one) {
+ let _ = session.retell(one, &body);
+ }
+ }
+ (
+ session.paths.data().to_path_buf(),
+ session.state.clone(),
+ session.dest(),
+ )
+}
+
+#[derive(serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Packed {
+ docs: usize,
+ pages: usize,
+ folders: usize,
+ files: usize,
+ missed: usize,
+ left: usize,
+}
+
+#[derive(serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Unpacked {
+ docs: usize,
+ pages: usize,
+ folders: usize,
+ joined: usize,
+ files: usize,
+ missed: usize,
+}
+
+#[tauri::command]
+fn spelled(said: String) -> String {
+ tisty_core::docs::spelled(&said)
+}
+
+#[tauri::command(async)]
+async fn docs_pack(
+ app: tauri::AppHandle,
+ session: tauri::State<'_, Mutex>,
+ alone: tauri::State<'_, Packing>,
+ which: Vec,
+ into: String,
+) -> Answer {
+ let _done = alone.inner().taken()?;
+ let (data, state, beside) = standing(&session, &which);
+ let asked = which.clone();
+ let at = into.clone();
+ let telling = along_the_way(&app, "packing");
+ let sent = tauri::async_runtime::spawn_blocking(move || {
+ tisty_core::parcel::write(
+ &data,
+ &state,
+ &asked,
+ std::path::Path::new(&at),
+ &tisty_core::parcel::Along {
+ also: beside.as_deref(),
+ say: Some(&telling),
+ },
+ )
+ })
+ .await
+ .map_err(|_| Refusal::of("internal"))?
+ .map_err(|e| {
+ witness::warn(
+ channel::WINDOW,
+ "a parcel of documents could not be written",
+ &[("why", Fact::Why(e.to_string()))],
+ );
+ match e {
+ tisty_core::Error::TooBig => Refusal::of("tooBig"),
+ tisty_core::Error::NothingToCarry => Refusal::of("nothingToCarry"),
+ _ => Refusal::about("cannotWrite", into.clone()),
+ }
+ })?;
+
+ if !sent.left.is_empty() {
+ witness::warn(
+ channel::WINDOW,
+ "a parcel went out without everything it points at",
+ &[("left", Fact::Why(sent.left.join("; ")))],
+ );
+ }
+ Ok(Packed {
+ docs: sent.docs,
+ pages: sent.pages,
+ folders: sent.folders,
+ files: sent.files,
+ missed: sent.missed,
+ left: sent.left.len(),
+ })
+}
+
+#[tauri::command(async)]
+async fn docs_take_out(
+ app: tauri::AppHandle,
+ session: tauri::State<'_, Mutex>,
+ alone: tauri::State<'_, Packing>,
+ which: Vec,
+ into: String,
+) -> Answer {
+ let _done = alone.inner().taken()?;
+ let (data, state, beside) = standing(&session, &which);
+ let asked = which.clone();
+ let at = into.clone();
+ let telling = along_the_way(&app, "takingOut");
+ let sent = tauri::async_runtime::spawn_blocking(move || {
+ tisty_core::parcel::plainly(
+ &data,
+ &state,
+ &asked,
+ std::path::Path::new(&at),
+ &tisty_core::parcel::Along {
+ also: beside.as_deref(),
+ say: Some(&telling),
+ },
+ )
+ })
+ .await
+ .map_err(|_| Refusal::of("internal"))?
+ .map_err(|e| {
+ witness::warn(
+ channel::WINDOW,
+ "the documents could not be taken out",
+ &[("why", Fact::Why(e.to_string()))],
+ );
+ match e {
+ tisty_core::Error::NothingToCarry => Refusal::of("nothingToCarry"),
+ _ => Refusal::about("cannotWrite", into.clone()),
+ }
+ })?;
+
+ if !sent.left.is_empty() {
+ witness::warn(
+ channel::WINDOW,
+ "documents went out without everything they point at",
+ &[("left", Fact::Why(sent.left.join("; ")))],
+ );
+ }
+ Ok(Packed {
+ docs: sent.docs,
+ pages: sent.pages,
+ folders: sent.folders,
+ files: sent.files,
+ missed: sent.missed,
+ left: sent.left.len(),
+ })
+}
+
+#[tauri::command(async)]
+async fn docs_unpack(
+ app: tauri::AppHandle,
+ session: tauri::State<'_, Mutex>,
+ alone: tauri::State<'_, Packing>,
+ from: String,
+) -> Answer {
+ let _done = alone.inner().taken()?;
+ let (data, state, device) = {
+ let session = held(&session);
+ (
+ session.paths.data().to_path_buf(),
+ session.state.clone(),
+ session.config.device_id.clone(),
+ )
+ };
+ let at = from.clone();
+ let telling = along_the_way(&app, "landing");
+ let (landed, ops) = tauri::async_runtime::spawn_blocking(move || {
+ tisty_core::parcel::read(
+ &data,
+ &state,
+ &device,
+ std::path::Path::new(&at),
+ &tisty_core::parcel::Along {
+ also: None,
+ say: Some(&telling),
+ },
+ )
+ })
+ .await
+ .map_err(|_| Refusal::of("internal"))?
+ .map_err(|e| match e {
+ tisty_core::Error::NotAParcel(_) => Refusal::about("notAParcel", from.clone()),
+ tisty_core::Error::ParcelNewer(_) => Refusal::of("parcelNewer"),
+ tisty_core::Error::TooBig => Refusal::of("tooBig"),
+ other => blamed(channel::WINDOW, "a parcel could not be taken in", other),
+ })?;
+
+ held(&session)
+ .commit_all(ops)
+ .map_err(|e| blamed(channel::WINDOW, "a parcel landed but was not written", e))?;
+ Ok(Unpacked {
+ docs: landed.docs,
+ pages: landed.pages,
+ folders: landed.folders,
+ joined: landed.joined,
+ files: landed.files,
+ missed: landed.missed,
+ })
}
#[tauri::command(async)]
@@ -3707,15 +4135,21 @@ fn doc_import(
.filter(|one| one.folder == folder)
.map(|one| one.order.as_str()),
);
+ let signed_as = signing(&session.state);
session.commit(Op::DocAdd {
id: ulid::Ulid::generate(),
d: tisty_core::event::DocAdd {
+ wrote: None,
+ guest: false,
+ made: None,
+ by: signed_as.clone(),
file: made.id.clone(),
order,
said: Some(tisty_core::event::Said {
title: made.title.clone(),
bytes: None,
tags: Some(Vec::new()),
+ by: None,
}),
folder,
page_of: None,
@@ -3777,15 +4211,21 @@ fn doc_new(
.filter(|one| one.page_of == page_of && (page_of.is_some() || one.folder == folder))
.map(|one| one.order.as_str()),
);
+ let signed_as = signing(&session.state);
session.commit(Op::DocAdd {
id: ulid::Ulid::generate(),
d: tisty_core::event::DocAdd {
+ wrote: None,
+ guest: false,
+ made: None,
+ by: signed_as.clone(),
file: made.id.clone(),
order,
said: Some(tisty_core::event::Said {
title: made.title.clone(),
bytes: None,
tags: Some(Vec::new()),
+ by: None,
}),
folder,
page_of,
@@ -4582,7 +5022,10 @@ fn convert_paper(
) -> Answer<()> {
let mut session = held(&session);
if session.state.bolted(&id) {
- return Err(Refusal::of("documentLocked"));
+ return Err(Refusal::of(match session.state.away(&id) {
+ true => "documentAway",
+ false => "documentLocked",
+ }));
}
let papers = session.paths.docs();
let was = tisty_core::docs::read(&papers, &id)
@@ -4638,8 +5081,11 @@ fn print_of_three(base: &str, mine: &str, theirs: &str) -> String {
#[tauri::command(async)]
fn paper_rifts(session: tauri::State<'_, Mutex>, id: String) -> Answer {
let session = held(&session);
- if session.state.bolted(&id) {
- return Err(Refusal::of("documentLocked"));
+ if session.state.shut_tight(&id) {
+ return Err(Refusal::of(match session.state.away(&id) {
+ true => "documentAway",
+ false => "documentLocked",
+ }));
}
let Some((base, mine, theirs)) = three_bodies(&session, &id)? else {
return Ok(Torn {
@@ -4662,7 +5108,10 @@ fn weave_paper(
) -> Answer<()> {
let mut session = held(&session);
if session.state.bolted(&id) {
- return Err(Refusal::of("documentLocked"));
+ return Err(Refusal::of(match session.state.away(&id) {
+ true => "documentAway",
+ false => "documentLocked",
+ }));
}
let Some((base, mine, theirs)) = three_bodies(&session, &id)? else {
return Err(Refusal::of("noBase"));
@@ -4701,7 +5150,10 @@ fn settle_paper(
) -> Answer> {
let mut session = held(&session);
if session.state.bolted(&id) {
- return Err(Refusal::of("documentLocked"));
+ return Err(Refusal::of(match session.state.away(&id) {
+ true => "documentAway",
+ false => "documentLocked",
+ }));
}
let Some(tisty_core::config::Sync::Folder(dest)) = session.config.sync.clone() else {
return Err(Refusal::of("noRemote"));
@@ -4731,10 +5183,15 @@ fn settle_paper(
.map_err(|e| blamed(channel::SYNC, "the other version could not be kept", e))?;
let file = made.id.clone();
let (folder, page_of, order) = placed(beside, &made.id);
+ let signed_as = signing(&session.state);
session
.commit(Op::DocAdd {
id: ulid::Ulid::generate(),
d: tisty_core::event::DocAdd {
+ wrote: None,
+ guest: false,
+ made: None,
+ by: signed_as.clone(),
file: file.clone(),
folder,
order,
@@ -4742,6 +5199,7 @@ fn settle_paper(
title: made.title.clone(),
bytes: None,
tags: Some(Vec::new()),
+ by: None,
}),
page_of,
},
@@ -5415,6 +5873,15 @@ fn worded(locale: &Option, key: &str) -> String {
#[derive(Default)]
struct Updating(OneAtATime);
+#[derive(Default)]
+struct Packing(OneAtATime);
+
+impl Packing {
+ fn taken(&self) -> Answer> {
+ self.0.claim().ok_or_else(|| Refusal::of("stillPacking"))
+ }
+}
+
#[derive(Default)]
struct OneAtATime(std::sync::atomic::AtomicBool);
@@ -5633,6 +6100,7 @@ pub fn run() {
}
})
.manage(OneAtATime::default())
+ .manage(Packing::default())
.manage(Updating::default())
.manage(Leaving::default())
.invoke_handler(tauri::generate_handler![
@@ -5740,6 +6208,13 @@ pub fn run() {
doc_drop,
doc_import,
doc_export,
+ signed,
+ sign,
+ sign_the_rest,
+ spelled,
+ docs_pack,
+ docs_take_out,
+ docs_unpack,
doc_copy,
doc_adopt,
doc_let_go,
@@ -5786,6 +6261,10 @@ mod deleting {
.commit(Op::DocAdd {
id: ulid::Ulid::generate(),
d: tisty_core::event::DocAdd {
+ wrote: None,
+ guest: false,
+ made: None,
+ by: None,
said: None,
file: made.id.clone(),
order: tisty_core::order::first(),
@@ -6673,12 +7152,17 @@ mod ordering {
.commit(Op::DocAdd {
id: ulid::Ulid::generate(),
d: tisty_core::event::DocAdd {
+ wrote: None,
+ guest: false,
+ made: None,
+ by: None,
file: file.clone(),
order: "a1".into(),
said: Some(tisty_core::event::Said {
title: tisty_core::docs::titled(super::GUIDE_ES),
bytes: None,
tags: Some(Vec::new()),
+ by: None,
}),
folder: None,
page_of: None,
@@ -6700,12 +7184,17 @@ mod ordering {
.commit(Op::DocAdd {
id: ulid::Ulid::generate(),
d: tisty_core::event::DocAdd {
+ wrote: None,
+ guest: false,
+ made: None,
+ by: None,
file: "notas-c3d4".into(),
order: "a1".into(),
said: Some(tisty_core::event::Said {
title: "Mis notas".into(),
bytes: None,
tags: Some(Vec::new()),
+ by: None,
}),
folder: None,
page_of: None,
@@ -6785,6 +7274,10 @@ mod ordering {
.commit(Op::DocAdd {
id,
d: tisty_core::event::DocAdd {
+ wrote: None,
+ guest: false,
+ made: None,
+ by: None,
said: None,
file: name.into(),
order: order.into(),
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 5578b84..79920d5 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -1,11 +1,12 @@
import { listen } from "@tauri-apps/api/event";
-import { ask, open as pick } from "@tauri-apps/plugin-dialog";
+import { ask, save as intoFile, open as pick } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useRef, useState } from "react";
import { AXES } from "./archive";
import { carrying } from "./carrying";
import { heard, play } from "./chime";
import { asPlain } from "./copying";
import {
+ type Afoot,
attach,
type Change,
capture,
@@ -23,6 +24,9 @@ import {
docPage,
docs,
docsCatchUp,
+ docsPack,
+ docsTakeOut,
+ docsUnpack,
dropStep,
erase,
type Filed,
@@ -48,6 +52,7 @@ import {
settleIn,
snapshot,
sow,
+ spelled,
syncState,
type Task,
type Underway,
@@ -59,7 +64,7 @@ import {
import { decideAll, decidesByBlock } from "./deciding";
import { handTo, whenFilesLand } from "./dropped";
import { todayLong } from "./format";
-import { adopt, fill, t } from "./locales";
+import { adopt, fill, t, type Word } from "./locales";
import { noticeBehind, saidPlainly } from "./refusal";
import { settled } from "./saving";
import About from "./ui/About";
@@ -119,6 +124,8 @@ export const kept = (key: string): string[] => {
const LOOKS_AGAIN = 6 * 60 * 60 * 1000;
+const PARCEL = "tistyx";
+
export default function App() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
@@ -185,6 +192,7 @@ export default function App() {
const [makingFolder, setMakingFolder] = useState(false);
const [renaming, setRenaming] = useState(null);
const [note, setNote] = useState(null);
+ const [afoot, setAfoot] = useState(null);
const [menu, setMenu] = useState<{
at: { x: number; y: number };
label: string;
@@ -221,6 +229,111 @@ export default function App() {
})
.catch((e) => setError(saidPlainly(e)));
+ const packUp = (which: string[], named: string) =>
+ spelled(named)
+ .catch(() => "tisty")
+ .then((safe) =>
+ intoFile({
+ defaultPath: `${safe}.${PARCEL}`,
+ filters: [{ name: "Tisty", extensions: [PARCEL] }],
+ }),
+ )
+ .then((at) => {
+ if (typeof at !== "string") return null;
+ setAfoot({ stage: "packing", far: 0, done: 0, whole: 0 });
+ return docsPack(which, at);
+ })
+ .then((packed) => {
+ setAfoot(null);
+ if (!packed) return;
+ const many = packed.docs + packed.pages;
+ if (packed.missed > 0 || packed.left > 0) {
+ setError(
+ packed.missed > 0
+ ? fill("packedShort", String(many), String(packed.missed))
+ : fill("packedLess", String(many), String(packed.left)),
+ );
+ return;
+ }
+ setNote(
+ many === 1 ? t("packedOne") : many ? fill("packed", String(many)) : t("packedAlone"),
+ );
+ setTimeout(() => setNote(null), 3200);
+ })
+ .catch((e) => {
+ setAfoot(null);
+ setError(saidPlainly(e));
+ });
+
+ const takeOutAll = () =>
+ pick({ directory: true })
+ .then((at) => {
+ if (typeof at !== "string") return null;
+ setAfoot({ stage: "takingOut", far: 0, done: 0, whole: 0 });
+ return docsTakeOut([], at);
+ })
+ .then((took) => {
+ setAfoot(null);
+ if (!took) return;
+ const many = took.docs;
+ if (took.missed > 0 || took.left > 0) {
+ setError(
+ took.missed > 0
+ ? fill("packedShort", String(many), String(took.missed))
+ : fill("packedLess", String(many), String(took.left)),
+ );
+ return;
+ }
+ setNote(
+ many === 1
+ ? t("tookOutOne")
+ : took.folders
+ ? fill("tookOutAll", String(many), String(took.folders))
+ : fill("tookOutAllFlat", String(many)),
+ );
+ setTimeout(() => setNote(null), 3200);
+ })
+ .catch((e) => {
+ setAfoot(null);
+ setError(saidPlainly(e));
+ });
+
+ const takeParcel = () =>
+ pick({ multiple: false, filters: [{ name: "Tisty", extensions: [PARCEL] }] })
+ .then((at) => {
+ if (typeof at !== "string") return null;
+ setAfoot({ stage: "landing", far: 0, done: 0, whole: 0 });
+ return docsUnpack(at);
+ })
+ .then((landed) => {
+ setAfoot(null);
+ if (!landed) return;
+ papersChanged();
+ const many = landed.docs + landed.pages;
+ if (landed.missed > 0 && many > 0) {
+ setError(fill("packedShort", String(many), String(landed.missed)));
+ return;
+ }
+ if (many === 0) {
+ setError(
+ landed.missed > 0 ? fill("landedNoneOfIt", String(landed.missed)) : t("landedNone"),
+ );
+ return;
+ }
+ setNote(
+ many === 1
+ ? t("landedOne")
+ : landed.folders
+ ? fill("landedIn", String(many), String(landed.folders))
+ : fill("landedAlone", String(many)),
+ );
+ setTimeout(() => setNote(null), 3200);
+ })
+ .catch((e) => {
+ setAfoot(null);
+ setError(saidPlainly(e));
+ });
+
const dropFolder = (folder: Folded) =>
ask(fill("dropFolderSure", folder.name), { kind: "warning" })
.then((yes) => {
@@ -491,11 +604,15 @@ export default function App() {
const sound = listen("chime", (rung) => {
if (heard(rung.payload)) play(rung.payload);
});
+ const along = listen("carrying", (step) => {
+ setAfoot((was) => (was ? step.payload : was));
+ });
return () => {
stop.then((off) => off()).catch(() => {});
caught.then((off) => off()).catch(() => {});
stirred.then((off) => off()).catch(() => {});
sound.then((off) => off()).catch(() => {});
+ along.then((off) => off()).catch(() => {});
};
}, [lookPapers]);
@@ -612,6 +729,14 @@ export default function App() {
apart: true,
onPick: () => bringIn(undefined),
},
+ { key: "unpack", icon: "↧", label: t("unpackIt"), onPick: () => takeParcel() },
+ {
+ key: "packAll",
+ icon: "⇪",
+ label: t("packAll"),
+ onPick: () => packUp([], "tisty"),
+ },
+ { key: "takeOutAll", icon: "⇪", label: t("takeOutAll"), onPick: () => takeOutAll() },
],
});
@@ -655,6 +780,9 @@ export default function App() {
},
},
{ key: "import", icon: "↧", label: t("importDoc"), onPick: () => bringIn(folder.id) },
+ { key: "unpack", icon: "↧", label: t("unpackIt"), onPick: () => takeParcel() },
+ { key: "packAll", icon: "⇪", label: t("packAll"), onPick: () => packUp([], "tisty") },
+ { key: "takeOutAll", icon: "⇪", label: t("takeOutAll"), onPick: () => takeOutAll() },
{
key: "drop",
icon: "✕",
@@ -760,11 +888,23 @@ export default function App() {
);
return;
}
+ if (took.left > 0) {
+ setError(
+ took.left === 1 ? t("takenLess") : fill("takenLesser", String(took.left)),
+ );
+ return;
+ }
setNote(took.files ? fill("takenOut", String(took.files)) : t("takenOutAlone"));
setTimeout(() => setNote(null), 3200);
})
.catch((e) => setError(saidPlainly(e))),
},
+ {
+ key: "packIt",
+ icon: "⇪",
+ label: t("packIt"),
+ onPick: () => packUp([doc.file], doc.title || doc.file),
+ },
{
key: "seePdf",
icon: "▤",
@@ -922,7 +1062,7 @@ export default function App() {
)}
- {note && !error && (
+ {note && !error && !afoot && (
)}
+ {afoot && (
+
+
+ {fill(`${afoot.stage}On` as Word, afoot.far ? `${afoot.far} %` : "").trim()}
+
+ {t("aWhileYet")}
+
+
+
+
+ )}
+
{leaving && (
setLeaving(false)} onError={(e) => setError(saidPlainly(e))} />
)}
@@ -1071,6 +1230,9 @@ export default function App() {
apart: true,
onPick: () => bringIn(here ?? undefined),
},
+ { key: "unpack", icon: "↧", label: t("unpackIt"), onPick: () => takeParcel() },
+ { key: "packAll", icon: "⇪", label: t("packAll"), onPick: () => packUp([], "tisty") },
+ { key: "takeOutAll", icon: "⇪", label: t("takeOutAll"), onPick: () => takeOutAll() },
],
})
}
@@ -1178,6 +1340,8 @@ export default function App() {
) : chosen.named === "keeping" ? (
packUp([], "tisty")}
+ onUnpack={takeParcel}
onGreet={() => setGreet(true)}
onDoc={openDoc}
onChanged={() => {
diff --git a/app/src/core.ts b/app/src/core.ts
index 13b93f2..3bb6d19 100644
--- a/app/src/core.ts
+++ b/app/src/core.ts
@@ -669,6 +669,7 @@ export interface Filed {
archived: boolean;
locked?: boolean;
gone?: boolean;
+ guest?: string | null;
tags?: string[];
}
@@ -682,8 +683,23 @@ export interface DocFacts {
wrote: number | null;
bytes: number;
pages: number;
+ author: string | null;
+ editor: string | null;
+ born: string | null;
}
+export const ALIAS_AT_MOST = 40;
+
+export interface Signed {
+ alias: string | null;
+ before: string[];
+ mine: number;
+}
+
+export const signed = (): Promise => invoke("signed");
+export const sign = (alias?: string): Promise => invoke("sign", { alias });
+export const signTheRest = (): Promise => invoke("sign_the_rest");
+
export const docFacts = (id: string): Promise => invoke("doc_facts", { id });
export const docs = (): Promise => invoke("docs");
@@ -726,10 +742,43 @@ export const docCopy = (id: string): Promise => invoke("doc_copy", { id });
export interface Taken {
files: number;
missed: number;
+ left: number;
}
export const docExport = (id: string, into: string): Promise =>
invoke("doc_export", { id, into });
+
+export interface Afoot {
+ stage: "packing" | "takingOut" | "landing";
+ far: number;
+ done: number;
+ whole: number;
+}
+
+export interface Packed {
+ docs: number;
+ pages: number;
+ folders: number;
+ files: number;
+ missed: number;
+ left: number;
+}
+
+export interface Unpacked {
+ docs: number;
+ pages: number;
+ folders: number;
+ joined: number;
+ files: number;
+ missed: number;
+}
+
+export const spelled = (said: string): Promise => invoke("spelled", { said });
+export const docsPack = (which: string[], into: string): Promise =>
+ invoke("docs_pack", { which, into });
+export const docsUnpack = (from: string): Promise => invoke("docs_unpack", { from });
+export const docsTakeOut = (which: string[], into: string): Promise =>
+ invoke("docs_take_out", { which, into });
export const docImport = (from: string, folder?: string): Promise =>
invoke("doc_import", { from, folder });
diff --git a/app/src/locales.ts b/app/src/locales.ts
index ec1606f..ef73178 100644
--- a/app/src/locales.ts
+++ b/app/src/locales.ts
@@ -181,6 +181,7 @@ const en = {
showAsCard: "Show as a card",
keepACopy: "Save a copy…",
moreOnIt: "More",
+ guestDoc: "Written by {name}, and imported from another Tisty",
goneDoc: "That document is not on this machine",
docItself: "This very document",
goneFile: "not in the store",
@@ -353,6 +354,9 @@ const en = {
besideShut: "Close this column",
aboutPaper: "About this document",
paperMade: "Created",
+ paperAuthor: "Author",
+ paperEditor: "Edited by",
+ paperBorn: "Signed before as",
paperWrote: "Written",
paperLong: "Length",
paperWeighs: "Takes up",
@@ -439,10 +443,13 @@ const en = {
awayStaysAway: "A document put away has to come back before it can be a page",
folderNameTooLong: "A folder name fits 40 characters at most",
documentBeingWritten: "Something else is writing this document. Try again in a moment",
+ documentAway: "That one is in the archive. Bring it back to write in it",
documentLocked: "This document is locked. Unlock it before writing in it",
documentMoved: "Something wrote in this document while you had it open here",
docStirred: "Something wrote in this document, and what you are reading is what it left",
docBolted: "You locked this document. Nothing writes in it, not even an assistant",
+ docShelved:
+ "This one is in the archive. It reads, it does not write — bring it back to write in it again",
docStirredGone: "Got it",
comingDown: "{name} is in iCloud and is being brought back. Try again in a moment",
attachmentTorn:
@@ -472,11 +479,20 @@ const en = {
copyPlain: "Copy as Markdown",
anyFile: "Any file that is text",
toPdf: "Export to PDF…",
+ toPdfDo: "Export PDF",
+ pdfSign: "Add who wrote it and when",
+ pdfSignWhy: "A PDF leaves Tisty for good. What goes in it is what you choose to put in it.",
+ pdfBy: "Written by {name}",
+ pdfEditedBy: "Edited by {name}",
+ pdfMade: "Created {name}",
+ pdfWrote: "Last written {name}",
takeOut: "Export as Markdown…",
takenOut: "Taken out with {name} of its files",
takenOutAlone: "Taken out",
- takenShort: "Taken out, but one page could not be read and is not in it",
- takenShorter: "Taken out, but {name} pages could not be read and are not in it",
+ takenShort: "Taken out, but one thing could not be read and is not in it",
+ takenShorter: "Taken out, but {name} things could not be read and are not in it",
+ takenLess: "Taken out, but one file it points at is not here and is not in it",
+ takenLesser: "Taken out, but {name} files it points at are not here and are not in it",
copied: "Copied without the underline",
bigTitle: "Heading",
midTitle: "Subheading",
@@ -515,6 +531,33 @@ const en = {
dropDocShort: "Delete document",
notACadence: "That is not a repeat Tisty knows",
importDoc: "Import a document",
+ packIt: "Export for Tisty…",
+ packAll: "Export everything for Tisty…",
+ takeOutAll: "Export everything as Markdown…",
+ packingOn: "Packing… {name}",
+ takingOutOn: "Writing them out… {name}",
+ landingOn: "Taking it in… {name}",
+ aWhileYet: "Videos take their time. You can carry on working.",
+ tookOutAll: "{name} documents taken out, in {other} folders",
+ tookOutAllFlat: "{name} documents taken out",
+ tookOutOne: "One document taken out",
+ unpackIt: "Import from Tisty…",
+ packed: "{name} documents exported for Tisty",
+ packedOne: "One document exported for Tisty",
+ packedShort: "{name} went out, but {other} could not be read",
+ packedLess: "{name} went out, but {other} files they point at are not here",
+ packedAlone: "Exported for Tisty",
+ landedIn: "{name} documents came in, {other} folders among them",
+ landedAlone: "{name} documents came in",
+ landedOne: "One document came in",
+ landedNone: "Nothing came in: the parcel held no documents",
+ landedShort: "It came in, but {name} things did not fit and stayed out",
+ landedNoneOfIt: "Nothing of it came in: {name} things could not be read",
+ notAParcel: "{name} was not exported by Tisty",
+ parcelNewer: "That was exported by a newer Tisty than this one",
+ nothingToCarry: "There is nothing written here to take out yet",
+ stillPacking: "Something is already being carried in or out — give it a moment",
+ tooBig: "That is larger than Tisty will carry",
imported: "Imported as a new document",
renameFolder: "Rename {name}",
dropFolder: "Delete {name}",
@@ -941,6 +984,35 @@ const en = {
tabUpkeep: "Maintenance",
tabAgents: "Assistants",
bandWindow: "This window",
+ bandSigning: "Who writes here",
+ bandParcels: "Documents that travel",
+ packAllPlain: "Take your documents to another Tisty",
+ packAllWhy:
+ "One file with every document, its folders and what they carry. Not a backup: importing it twice makes a second copy.",
+ packAllDo: "Export…",
+ unpackPlain: "Bring documents from another Tisty",
+ unpackWhy:
+ "Documents somebody handed you, or your own from another machine. They land as new documents here.",
+ unpackDo: "Open…",
+ alias: "Alias",
+ aliasShort: "What you sign your writing with.",
+ aliasNone: "unsigned",
+ aliasRest: "Sign the older ones",
+ aliasNow: "You now sign as {name}",
+ aliasKept: "Signed as {name}",
+ aliasGone: "Your writing goes out unsigned from now on",
+ aliasRestAsk:
+ "What was already written keeps the signature it had. Do you want the {name} older documents to carry this alias instead?",
+ aliasRestAskOne:
+ "What was already written keeps the signature it had. Do you want the one older document to carry this alias instead?",
+ aliasRestNever: "What arrived from somebody else keeps their name, always.",
+ aliasRestYes: "Yes, change them all",
+ aliasRestNo: "No, only the new ones",
+ aliasRestDone: "{name} documents signed",
+ aliasRestDoneOne: "One document signed",
+ aliasWhy:
+ "Did you know you can share your documents with other people who use Tisty? It is something you do by hand: we do not know what you write or what you share.\n\nThe alias is how you sign them. It stays with you — and travels to your other machines if you sync. It goes inside whatever you export for Tisty, and into a PDF only if you ask for it while exporting; a plain markdown file carries no name at all. Only the person you hand the document to ever sees it.\n\nWe never upload anything to any service. There is no telemetry behind this either.",
+ aliasTooLong: "An alias holds up to {name} characters",
bandNotices: "Notices",
bandOutside: "Outside this window",
attachRow: "Take in files up to",
@@ -1124,6 +1196,16 @@ const en = {
notInThePath: "not in the PATH",
nothingBound: "nothing bound",
wordNone: "none",
+ welcomeSigning: "How should we call you?",
+ welcomeSigningWhy:
+ "Did you know you can share your documents with other people who use Tisty? It is something you do by hand: we do not know what you write or what you share.",
+ welcomeSigningHow:
+ "The alias is how you sign them. It stays with you — and travels to your other machines if you sync. It goes inside whatever you export for Tisty, and into a PDF only if you ask for it while exporting; a plain markdown file carries no name at all. Only the person you hand the document to ever sees it.",
+ welcomeSigningNote:
+ "We never upload anything to any service. There is no telemetry behind this either. It is optional, and you can change it whenever you like from Settings.",
+ welcomeSigned: "Done",
+ welcomeNotNow: "Not now",
+ aliasLike: "your name here",
welcomeRedundancy:
"Syncing gives you redundancy, not a way back in time: delete a task and the deletion travels too.",
};
@@ -1315,6 +1397,7 @@ const es: Catalog = {
showAsCard: "Verlo como tarjeta",
keepACopy: "Guardar una copia…",
moreOnIt: "Más",
+ guestDoc: "Lo escribió {name}, y se importó de otro Tisty",
goneDoc: "Ese documento no está en esta máquina",
docItself: "Este mismo documento",
goneFile: "no está en el almacén",
@@ -1487,6 +1570,9 @@ const es: Catalog = {
besideShut: "Cerrar esta columna",
aboutPaper: "Sobre este documento",
paperMade: "Creado",
+ paperAuthor: "Autor",
+ paperEditor: "Editado por",
+ paperBorn: "Antes firmado como",
paperWrote: "Escrito",
paperLong: "Extensión",
paperWeighs: "Ocupa",
@@ -1574,10 +1660,13 @@ const es: Catalog = {
folderNameTooLong: "El nombre de una carpeta admite 40 caracteres como máximo",
documentBeingWritten:
"Algo más está escribiendo este documento. Inténtalo de nuevo en un momento",
+ documentAway: "Ese está en el archivo. Desarchívalo para escribir en él",
documentLocked: "Este documento está bloqueado. Desbloquéalo antes de escribir en él",
documentMoved: "Algo escribió en este documento mientras lo tenías abierto aquí",
docStirred: "Algo escribió en este documento, y lo que lees es lo que dejó",
docBolted: "Bloqueaste este documento. Nada escribe en él, ni siquiera un asistente",
+ docShelved:
+ "Este está en el archivo. Se lee, no se escribe — desarchívalo para volver a escribir en él",
docStirredGone: "Entendido",
comingDown: "{name} está en iCloud y se está trayendo. Inténtalo de nuevo en un momento",
attachmentTorn:
@@ -1608,11 +1697,20 @@ const es: Catalog = {
copyPlain: "Copiar como Markdown",
anyFile: "Cualquier archivo que sea texto",
toPdf: "Exportar a PDF…",
+ toPdfDo: "Exportar PDF",
+ pdfSign: "Añadir quién lo escribió y cuándo",
+ pdfSignWhy: "Un PDF sale de Tisty para siempre. Dentro va lo que tú decidas poner.",
+ pdfBy: "Escrito por {name}",
+ pdfEditedBy: "Editado por {name}",
+ pdfMade: "Creado {name}",
+ pdfWrote: "Escrito por última vez {name}",
takeOut: "Exportar como Markdown…",
takenOut: "Exportado con {name} de sus archivos",
takenOutAlone: "Exportado",
- takenShort: "Exportado, pero una página no se pudo leer y no va dentro",
- takenShorter: "Exportado, pero {name} páginas no se pudieron leer y no van dentro",
+ takenShort: "Exportado, pero algo no se pudo leer y no va dentro",
+ takenShorter: "Exportado, pero {name} cosas no se pudieron leer y no van dentro",
+ takenLess: "Exportado, pero un archivo al que apunta no está y no va dentro",
+ takenLesser: "Exportado, pero {name} archivos a los que apunta no están y no van dentro",
copied: "Copiado sin el subrayado",
bigTitle: "Título",
midTitle: "Subtítulo",
@@ -1651,6 +1749,33 @@ const es: Catalog = {
dropDocShort: "Borrar documento",
notACadence: "Esa no es una repetición que Tisty conozca",
importDoc: "Importar un documento",
+ packIt: "Exportar para Tisty…",
+ packAll: "Exportar todo para Tisty…",
+ takeOutAll: "Exportar todo como Markdown…",
+ packingOn: "Empaquetando… {name}",
+ takingOutOn: "Escribiéndolos fuera… {name}",
+ landingOn: "Trayéndolo… {name}",
+ aWhileYet: "Los vídeos tardan lo suyo. Puedes seguir trabajando.",
+ tookOutAll: "Exportados {name} documentos, en {other} carpetas",
+ tookOutAllFlat: "Exportados {name} documentos",
+ tookOutOne: "Exportado un documento",
+ unpackIt: "Importar desde Tisty…",
+ packed: "Exportados {name} documentos para Tisty",
+ packedOne: "Exportado un documento para Tisty",
+ packedShort: "Salieron {name}, pero {other} no se pudieron leer",
+ packedLess: "Salieron {name}, pero {other} archivos a los que apuntan no están",
+ packedAlone: "Exportado para Tisty",
+ landedIn: "Entraron {name} documentos, con {other} carpetas",
+ landedAlone: "Entraron {name} documentos",
+ landedOne: "Entró un documento",
+ landedNone: "No entró nada: el paquete no traía documentos",
+ landedShort: "Entró, pero {name} cosas no cupieron y quedaron fuera",
+ landedNoneOfIt: "No entró nada: {name} cosas no se pudieron leer",
+ notAParcel: "{name} no lo exportó Tisty",
+ parcelNewer: "Eso lo exportó un Tisty más nuevo que este",
+ nothingToCarry: "Aquí todavía no hay nada escrito que sacar",
+ stillPacking: "Ya hay algo entrando o saliendo — dale un momento",
+ tooBig: "Eso pasa de lo que Tisty puede llevar",
imported: "Importado como documento nuevo",
renameFolder: "Renombrar {name}",
dropFolder: "Borrar {name}",
@@ -2077,6 +2202,35 @@ const es: Catalog = {
tabUpkeep: "Mantenimiento",
tabAgents: "Asistentes",
bandWindow: "Esta ventana",
+ bandSigning: "Quién escribe aquí",
+ bandParcels: "Documentos que viajan",
+ packAllPlain: "Llevar tus documentos a otro Tisty",
+ packAllWhy:
+ "Un archivo con todos los documentos, sus carpetas y lo que llevan dentro. No es una copia de seguridad: importarlo dos veces deja todo duplicado.",
+ packAllDo: "Exportar…",
+ unpackPlain: "Traer documentos de otro Tisty",
+ unpackWhy:
+ "Documentos que alguien te pasó, o los tuyos desde otro equipo. Aquí entran como documentos nuevos.",
+ unpackDo: "Abrir…",
+ alias: "Alias",
+ aliasShort: "Con lo que firmas lo que escribes.",
+ aliasNone: "sin firmar",
+ aliasRest: "Firmar los anteriores",
+ aliasNow: "Ahora firmas como {name}",
+ aliasKept: "Firmas como {name}",
+ aliasGone: "A partir de ahora lo que escribas sale sin firma",
+ aliasRestAsk:
+ "Lo que ya estaba escrito conserva la firma que tenía. ¿Quieres que los {name} documentos anteriores pasen a este alias?",
+ aliasRestAskOne:
+ "Lo que ya estaba escrito conserva la firma que tenía. ¿Quieres que el documento anterior pase a este alias?",
+ aliasRestNever: "Lo que llegó de otra persona conserva su nombre, siempre.",
+ aliasRestYes: "Sí, cambiar todos",
+ aliasRestNo: "No, solo los nuevos",
+ aliasRestDone: "{name} documentos firmados",
+ aliasRestDoneOne: "Un documento firmado",
+ aliasWhy:
+ "¿Sabías que puedes compartir tus documentos con otras personas que usan Tisty? Es algo que haces tú, a mano: nosotros no sabemos lo que escribes ni lo que compartes.\n\nEl alias es cómo los firmas. Se guarda contigo —y viaja a tus equipos si sincronizas—. Va dentro de lo que exportas para Tisty, y en un PDF solo si lo pides al exportarlo; en un markdown suelto no viaja. Solo lo ve aquella persona con quien compartas el documento.\n\nNunca subimos nada a ningún servicio. Tampoco hay telemetría detrás.",
+ aliasTooLong: "Un alias admite hasta {name} caracteres",
bandNotices: "Avisos",
bandOutside: "Fuera de esta ventana",
attachRow: "Aceptar archivos de hasta",
@@ -2261,6 +2415,16 @@ const es: Catalog = {
notInThePath: "fuera del PATH",
nothingBound: "sin asignar",
wordNone: "ninguno",
+ welcomeSigning: "¿Cómo quieres que te llamemos?",
+ welcomeSigningWhy:
+ "¿Sabías que puedes compartir tus documentos con otras personas que usan Tisty? Es algo que haces tú, a mano: nosotros no sabemos lo que escribes ni lo que compartes.",
+ welcomeSigningHow:
+ "El alias es cómo los firmas. Se guarda contigo —y viaja a tus equipos si sincronizas—. Va dentro de lo que exportas para Tisty, y en un PDF solo si lo pides al exportarlo; en un markdown suelto no viaja. Solo lo ve aquella persona con quien compartas el documento.",
+ welcomeSigningNote:
+ "Nunca subimos nada a ningún servicio. Tampoco hay telemetría detrás. Es opcional y lo cambias cuando quieras desde Ajustes.",
+ welcomeSigned: "Listo",
+ welcomeNotNow: "Ahora no",
+ aliasLike: "tu nombre aquí",
welcomeRedundancy:
"Sincronizar te da redundancia, no vuelta atrás en el tiempo: si borras una tarea, el borrado también viaja.",
};
diff --git a/app/src/refusal.ts b/app/src/refusal.ts
index 6645d3f..e0570c6 100644
--- a/app/src/refusal.ts
+++ b/app/src/refusal.ts
@@ -65,6 +65,12 @@ const KNOWN = [
"internalNamed",
"noSuchFolder",
"noSuchDoc",
+ "notAParcel",
+ "parcelNewer",
+ "nothingToCarry",
+ "stillPacking",
+ "aliasTooLong",
+ "tooBig",
"deleteRefused",
"alreadyKept",
"shedAlready",
@@ -80,6 +86,7 @@ const KNOWN = [
"folderNameTooLong",
"documentBeingWritten",
"documentLocked",
+ "documentAway",
"pageOfLocked",
"lockedStaysPut",
"lockIsTheDocs",
diff --git a/app/src/tests/beside.test.tsx b/app/src/tests/beside.test.tsx
index bc3e5e6..988ad21 100644
--- a/app/src/tests/beside.test.tsx
+++ b/app/src/tests/beside.test.tsx
@@ -1,4 +1,4 @@
-import { act, fireEvent, render, screen } from "@testing-library/react";
+import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useEffect } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -10,21 +10,39 @@ import Docs from "../ui/Docs";
const store = vi.hoisted(() => ({
ran: [] as string[],
went: [] as string[],
+ author: null as string | null,
+ editor: null as string | null,
+ born: null as string | null,
+ sent: [] as string[],
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: (cmd: string) => {
+ store.sent.push(cmd);
switch (cmd) {
case "doc_read":
return Promise.resolve("# Compras\n\nleche");
case "doc_facts":
- return Promise.resolve({ made: 1772668800, wrote: 1772755200, bytes: 8400 });
+ return Promise.resolve({
+ made: 1772668800,
+ wrote: 1772755200,
+ bytes: 8400,
+ author: store.author,
+ editor: store.editor,
+ born: store.born,
+ });
default:
return Promise.resolve(null);
}
},
}));
+vi.mock("@tauri-apps/plugin-dialog", () => ({
+ save: () => Promise.resolve("D:/salida/Compras.pdf"),
+ open: () => Promise.resolve(null),
+ ask: () => Promise.resolve(true),
+}));
+
vi.mock("@react-pdf/renderer", () => ({
pdf: () => ({ toBlob: async () => new Blob(["%PDF"], { type: "application/pdf" }) }),
Document: () => null,
@@ -105,9 +123,34 @@ describe("the column beside a document", () => {
beforeEach(() => {
store.ran = [];
store.went = [];
+ store.author = null;
+ store.editor = null;
+ store.born = null;
+ store.sent = [];
widen(1500);
});
+ it("says nothing about who wrote it while nobody has signed this store", async () => {
+ show();
+
+ const aside = await screen.findByRole("complementary", { name: "About this document" });
+ await within(aside).findByText(/kB/);
+ expect(within(aside).queryByText("Author")).toBeNull();
+ expect(within(aside).queryByText("Edited by")).toBeNull();
+ });
+
+ it("names the author, and names the editor only when somebody else wrote it", async () => {
+ store.author = "fulanito";
+ store.editor = "rgdevment";
+ show();
+
+ const aside = await screen.findByRole("complementary", { name: "About this document" });
+ await within(aside).findByText("Author");
+ expect(within(aside).getByText("fulanito")).toBeTruthy();
+ expect(within(aside).getByText("Edited by")).toBeTruthy();
+ expect(within(aside).getByText("rgdevment")).toBeTruthy();
+ });
+
it("shows itself the first time the window is wide enough", async () => {
show();
@@ -296,6 +339,23 @@ describe("what the column offers for the document", () => {
expect(named()).toEqual(["Preview", "Export", "Copy", "Save a copy"]);
});
+ it("asks before a PDF leaves, and signs it only when it was asked to", async () => {
+ store.author = "rgdevment";
+ widen(1500);
+ show();
+ await screen.findByRole("complementary", { name: "About this document" });
+
+ await userEvent.click(screen.getByRole("button", { name: /^export$/i }));
+
+ const box = await screen.findByText("Add who wrote it and when");
+ const tick = within(box.closest("label") as HTMLElement).getByRole("checkbox");
+ expect((tick as HTMLInputElement).checked).toBe(false);
+ await userEvent.click(tick);
+ await userEvent.click(screen.getByRole("button", { name: /^export pdf$/i }));
+
+ await waitFor(() => expect(store.sent).toContain("keep_pdf"));
+ });
+
it("keeps the two trades apart, so no verb has to mean two things", async () => {
widen(1500);
show();
diff --git a/app/src/tests/keeping.test.tsx b/app/src/tests/keeping.test.tsx
index c986e8b..c00c002 100644
--- a/app/src/tests/keeping.test.tsx
+++ b/app/src/tests/keeping.test.tsx
@@ -1,7 +1,7 @@
-import { render, screen, waitFor, within } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { adopt } from "../locales";
+import { adopt, t } from "../locales";
import Keeping from "../ui/Keeping";
import Welcome from "../ui/Welcome";
@@ -28,6 +28,12 @@ const installed = vi.hoisted(() => ({
}[],
}));
+const signing = vi.hoisted(() => ({
+ alias: null as string | null,
+ before: [] as string[],
+ mine: 0,
+}));
+
const asked = vi.hoisted(() => ({
folder: null as string | null,
file: null as string | null,
@@ -134,6 +140,9 @@ beforeEach(() => {
{ key: "icloud", named: "iCloud Drive" },
];
holders.told = { keeper: "plain" };
+ signing.alias = null;
+ signing.before = [];
+ signing.mine = 0;
ipc.answer = (cmd) => {
switch (cmd) {
case "keepers":
@@ -144,8 +153,27 @@ beforeEach(() => {
}
case "make_room":
return Promise.resolve(null);
+ case "sign_the_rest": {
+ const many = signing.mine;
+ signing.mine = 0;
+ return Promise.resolve(many);
+ }
case "sync_state":
return Promise.resolve({ ...carrying });
+ case "signed":
+ return Promise.resolve({ ...signing });
+ case "sign": {
+ const said = ipc.calls[ipc.calls.length - 1]?.args.alias;
+ if ((typeof said === "string" ? said : null) === signing.alias)
+ return Promise.resolve({ ...signing });
+ signing.alias = typeof said === "string" ? said : null;
+ if (signing.alias)
+ signing.before = [
+ signing.alias,
+ ...signing.before.filter((one) => one !== signing.alias),
+ ];
+ return Promise.resolve({ ...signing });
+ }
case "agent":
return Promise.resolve({ ...serving });
case "agent_turn": {
@@ -333,7 +361,15 @@ const turnedOn = async () => {
describe("the maintenance panel", () => {
it("offers to turn syncing on when there is no folder", async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
@@ -343,7 +379,15 @@ describe("the maintenance panel", () => {
});
it("offers the same list here as on the first run, never a bare file dialog", async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await userEvent.click(screen.getByRole("button", { name: /turn on/i }));
@@ -354,7 +398,15 @@ describe("the maintenance panel", () => {
it("remembers the folder that was picked", async () => {
asked.folder = "G:/My Drive/tisty";
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await turnedOn();
@@ -366,7 +418,15 @@ describe("the maintenance panel", () => {
it("hides backing up once a shared folder holds every machine", async () => {
carrying.chosen = "G:/My Drive/tisty";
carrying.backsUp = false;
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
@@ -377,7 +437,15 @@ describe("the maintenance panel", () => {
it("never restores without asking first", async () => {
asked.file = "C:/keep/tisty-backup.zip";
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await userEvent.click(screen.getByRole("button", { name: /restore/i }));
@@ -388,7 +456,15 @@ describe("the maintenance panel", () => {
it("restores once the warning is accepted", async () => {
asked.file = "C:/keep/tisty-backup.zip";
asked.sure = true;
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await userEvent.click(screen.getByRole("button", { name: /restore/i }));
@@ -401,7 +477,15 @@ describe("the maintenance panel", () => {
carrying.chosen = "G:/My Drive/tisty";
const otherwise = ipc.answer;
ipc.answer = (cmd, args) => (cmd === "sync_now" ? new Promise(() => {}) : otherwise(cmd, args));
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await userEvent.click(screen.getByRole("button", { name: /sync now/i }));
@@ -426,7 +510,15 @@ describe("the maintenance panel", () => {
: cmd === "keep_settings"
? new Promise(() => {})
: otherwise(cmd, args);
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await ready();
await userEvent.click(await screen.findByRole("switch", { name: /a short tone/i }));
@@ -439,7 +531,15 @@ describe("the maintenance panel", () => {
});
it("offers where the big attachments live, and says when the choice is idle", async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
const where = await screen.findByRole("combobox", { name: /where large attachments/i });
@@ -467,7 +567,15 @@ describe("the maintenance panel", () => {
})
: was(cmd, args)
)(ipc.answer);
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
const where = await screen.findByRole("combobox", { name: /where large attachments/i });
@@ -480,7 +588,15 @@ describe("the maintenance panel", () => {
});
it("frees the disk when told the big ones live in the shared folder, and can be stopped", async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
const where = await screen.findByRole("combobox", { name: /where large attachments/i });
@@ -493,7 +609,15 @@ describe("the maintenance panel", () => {
});
it("says which loose files are up in the shared folder", async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
await userEvent.click(screen.getByRole("button", { name: /^review$/i }));
@@ -503,7 +627,15 @@ describe("the maintenance panel", () => {
});
it("names every machine and when each last wrote", async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
@@ -515,7 +647,15 @@ describe("the maintenance panel", () => {
});
it("calls every machine something a person can read out loud", async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
@@ -526,7 +666,15 @@ describe("the maintenance panel", () => {
});
it("never offers to remove the machine you are on", async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
@@ -539,7 +687,15 @@ describe("the maintenance panel", () => {
it("names the machine and when it last wrote before removing it", async () => {
asked.sure = false;
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
await userEvent.click(screen.getByRole("button", { name: /^review$/i }));
@@ -553,7 +709,15 @@ describe("the maintenance panel", () => {
});
it("says out loud that a machine has been away, so nothing is judged on stale news", async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
@@ -575,7 +739,15 @@ describe("the maintenance panel", () => {
machines: [{ id: "mac0-0001", when: Math.floor(Date.now() / 1000), mine: true }],
}))
: otherwise(cmd, args);
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
@@ -587,7 +759,15 @@ describe("the maintenance panel", () => {
it("never lets go of an attachment without being told twice", async () => {
asked.sure = false;
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
await userEvent.click(screen.getByRole("button", { name: /^review$/i }));
@@ -600,7 +780,15 @@ describe("the maintenance panel", () => {
it("lets go of the one it was pointed at, and looks again afterwards", async () => {
asked.sure = true;
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
await userEvent.click(screen.getByRole("button", { name: /^review$/i }));
@@ -623,7 +811,15 @@ describe("the maintenance panel", () => {
said.push(text);
return was(text);
});
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
await userEvent.click(screen.getByRole("button", { name: /^review$/i }));
@@ -643,7 +839,15 @@ describe("the maintenance panel", () => {
cmd === "retire_attachment"
? Promise.reject({ code: "stillReferenced", name: "charla-a3f9.mp4" })
: otherwise(cmd, args);
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
await userEvent.click(screen.getByRole("button", { name: /^review$/i }));
@@ -657,7 +861,15 @@ describe("the maintenance panel", () => {
});
it("offers to remove another machine, never this one", async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
@@ -669,7 +881,15 @@ describe("the maintenance panel", () => {
it("never removes a machine without being told twice", async () => {
asked.sure = false;
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
await userEvent.click(screen.getByRole("button", { name: /^review$/i }));
@@ -682,7 +902,15 @@ describe("the maintenance panel", () => {
it("removes the machine it was pointed at, and looks again afterwards", async () => {
asked.sure = true;
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
await userEvent.click(screen.getByRole("button", { name: /^review$/i }));
@@ -697,7 +925,15 @@ describe("the maintenance panel", () => {
});
it("tells you to settle the machines before judging what is left over", async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
@@ -715,7 +951,15 @@ describe("the maintenance panel", () => {
machines: [{ id: "mac0-0001", when: Math.floor(Date.now() / 1000), mine: true }],
}))
: otherwise(cmd, args);
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
@@ -726,7 +970,15 @@ describe("the maintenance panel", () => {
});
it("shows each loose attachment by name, weight and date", async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
@@ -747,7 +999,15 @@ describe("the maintenance panel", () => {
},
])
: otherwise(cmd, args);
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
@@ -762,7 +1022,15 @@ describe("the maintenance panel", () => {
it("says nothing is kept twice when nothing is", async () => {
const otherwise = ipc.answer;
ipc.answer = (cmd, args) => (cmd === "twinned" ? Promise.resolve([]) : otherwise(cmd, args));
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
@@ -772,7 +1040,15 @@ describe("the maintenance panel", () => {
});
it("says plainly that another machine may still be using them", async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
@@ -782,7 +1058,15 @@ describe("the maintenance panel", () => {
});
it("breaks the weight down, so the size has somewhere to come from", async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
@@ -796,7 +1080,15 @@ describe("the maintenance panel", () => {
it("asks after days of hearing nothing, and blames neither side", async () => {
carrying.chosen = "G:/My Drive/tisty";
carrying.heard = new Date(Date.now() - 5 * 86_400_000).toISOString();
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
const said = await screen.findByText(/without anything arriving/i);
@@ -808,7 +1100,15 @@ describe("the maintenance panel", () => {
it("stays quiet while the other machines are still turning up", async () => {
carrying.chosen = "G:/My Drive/tisty";
carrying.heard = new Date(Date.now() - 3_600_000).toISOString();
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
expect(screen.queryByText(/without anything arriving/i)).toBeNull();
@@ -821,7 +1121,15 @@ describe("the maintenance panel", () => {
? Promise.resolve({ carried: "sent", undecided: [], unreadable: [] })
: otherwise(cmd, args);
carrying.chosen = "G:/My Drive/tisty";
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await userEvent.click(screen.getByRole("button", { name: /sync now/i }));
@@ -844,7 +1152,15 @@ describe("the maintenance panel", () => {
return Promise.resolve(true);
});
carrying.chosen = "G:/My Drive/tisty";
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await userEvent.click(screen.getByRole("button", { name: /sync now/i }));
@@ -864,7 +1180,15 @@ describe("the maintenance panel", () => {
let asked = 0;
vi.spyOn(dialog, "ask").mockImplementation(() => Promise.resolve(++asked > 1));
carrying.chosen = "G:/My Drive/tisty";
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await userEvent.click(screen.getByRole("button", { name: /sync now/i }));
@@ -878,7 +1202,15 @@ describe("the maintenance panel", () => {
const dialog = await import("@tauri-apps/plugin-dialog");
const spy = vi.spyOn(dialog, "ask");
carrying.chosen = "G:/My Drive/tisty";
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await userEvent.click(screen.getByRole("button", { name: /sync now/i }));
@@ -909,14 +1241,30 @@ describe("the maintenance panel", () => {
const carried = async () => {
carrying.chosen = "G:/My Drive/tisty";
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await userEvent.click(screen.getByRole("button", { name: /sync now/i }));
};
it("carries as soon as a folder is picked, so the doors open there and not later", async () => {
asked.folder = "G:/My Drive/tisty";
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await turnedOn();
@@ -928,7 +1276,15 @@ describe("the maintenance panel", () => {
it("opens the doors on picking a folder that already holds another history", async () => {
apart();
asked.folder = "G:/My Drive/tisty";
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await turnedOn();
@@ -939,7 +1295,15 @@ describe("the maintenance panel", () => {
it("does not leave you pointing at a folder you walked away from", async () => {
apart();
asked.folder = "G:/My Drive/tisty";
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await turnedOn();
@@ -954,7 +1318,15 @@ describe("the maintenance panel", () => {
onceApart();
asked.folder = "G:/My Drive/tisty";
asked.file = "C:/keep/tisty-folder-before.zip";
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await turnedOn();
@@ -1019,7 +1391,15 @@ describe("the maintenance panel", () => {
})
: otherwise(cmd, args);
carrying.chosen = "G:/My Drive/tisty";
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await userEvent.click(screen.getByRole("button", { name: /sync now/i }));
@@ -1161,7 +1541,15 @@ describe("the maintenance panel", () => {
});
it("names the documents that would open read only, and what each brings", async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
@@ -1176,7 +1564,15 @@ describe("the maintenance panel", () => {
const otherwise = ipc.answer;
ipc.answer = (cmd, args) =>
cmd === "doc_read" ? Promise.resolve("# Limpio\n\nun parrafo") : otherwise(cmd, args);
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
@@ -1186,7 +1582,15 @@ describe("the maintenance panel", () => {
});
it("says what the review found", async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
@@ -1198,18 +1602,42 @@ describe("the maintenance panel", () => {
it("reads its settings again once the welcome has been through", async () => {
const { rerender } = render(
- {}} onChanged={() => {}} onDoc={() => {}} />,
+ {}}
+ onUnpack={() => {}}
+ greeted={0}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
);
await data();
const before = sent("settings").length;
- rerender( {}} onChanged={() => {}} onDoc={() => {}} />);
+ rerender(
+ {}}
+ onUnpack={() => {}}
+ greeted={1}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await waitFor(() => expect(sent("settings").length).toBe(before + 1));
});
it("holds a language of its own when one is picked", async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await ready();
await userEvent.selectOptions(await screen.findByLabelText(/^language$/i), "es");
@@ -1220,7 +1648,15 @@ describe("the maintenance panel", () => {
it("offers the welcome again, without touching what is written", async () => {
const greet = vi.fn();
- render( {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={greet}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await ready();
await userEvent.click(await screen.findByRole("button", { name: /show it again/i }));
@@ -1231,7 +1667,15 @@ describe("the maintenance panel", () => {
it("opens the guide instead of only saying where it went", async () => {
const opened = vi.fn();
- render( {}} onChanged={() => {}} onDoc={opened} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={opened}
+ />,
+ );
await ready();
await userEvent.click(await screen.findByRole("button", { name: /open the guide/i }));
@@ -1244,6 +1688,7 @@ describe("the maintenance panel", () => {
describe("the first-run assistant", () => {
const spoken = async () => {
await userEvent.click(await screen.findByRole("button", { name: /^english$/i }));
+ await userEvent.click(await screen.findByRole("button", { name: t("welcomeNotNow") }));
};
const alone = async () => {
@@ -1409,12 +1854,37 @@ describe("the first-run assistant", () => {
expect(done.mock.calls[0][0]).toBeUndefined();
});
+ it("asks for an alias between the language and the copies, and writes it down", async () => {
+ render( );
+ await userEvent.click(await screen.findByRole("button", { name: /^english$/i }));
+
+ const field = await screen.findByRole("textbox", { name: /^alias$/i });
+ await userEvent.type(field, "rgdevment");
+ await userEvent.click(screen.getByRole("button", { name: t("welcomeSigned") }));
+
+ await waitFor(() => expect(sent("sign")).toHaveLength(1));
+ expect(sent("sign")[0].args.alias).toBe("rgdevment");
+ expect(await screen.findByRole("button", { name: /google drive/i })).toBeTruthy();
+ });
+
+ it("lets the alias wait, and writes nothing down when it is skipped", async () => {
+ render( );
+ await userEvent.click(await screen.findByRole("button", { name: /^english$/i }));
+ await screen.findByRole("textbox", { name: /^alias$/i });
+
+ await userEvent.click(screen.getByRole("button", { name: t("welcomeNotNow") }));
+
+ expect(await screen.findByRole("button", { name: /google drive/i })).toBeTruthy();
+ expect(sent("sign")).toHaveLength(0);
+ });
+
it("goes back, and shows what was already chosen", async () => {
render( );
await spoken();
await screen.findByRole("button", { name: /google drive/i });
await userEvent.click(screen.getByRole("button", { name: /^back$/i }));
+ await userEvent.click(await screen.findByRole("button", { name: /^back$/i }));
const english = await screen.findByRole("button", { name: /^english$/i });
expect(english.getAttribute("aria-pressed")).toBe("true");
@@ -1424,14 +1894,141 @@ describe("the first-run assistant", () => {
render( );
await spoken();
await userEvent.click(await screen.findByRole("button", { name: /^back$/i }));
+ await userEvent.click(await screen.findByRole("button", { name: /^back$/i }));
await userEvent.click(screen.getByRole("button", { name: /^español$/i }));
expect(sent("keep_locale").map((one) => one.args.locale)).toEqual(["en", "es"]);
});
+ it("keeps the alias the person signs with, and shows it again on the next look", async () => {
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
+ await ready();
+
+ const field = await screen.findByRole("textbox", { name: /^alias$/i });
+ expect((field as HTMLInputElement).value).toBe("");
+ await userEvent.type(field, " rgdevment ");
+ fireEvent.blur(field);
+
+ await waitFor(() => expect(sent("sign")).toHaveLength(1));
+ expect(sent("sign")[0].args.alias).toBe("rgdevment");
+ await waitFor(() => expect((field as HTMLInputElement).value).toBe("rgdevment"));
+ });
+
+ it("offers the aliases this store signed with before, and not the one in use", async () => {
+ signing.alias = "rgdevment";
+ signing.before = ["rgdevment", "mario"];
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
+ await ready();
+
+ const field = await screen.findByRole("combobox", { name: /^alias$/i });
+ expect((field as HTMLInputElement).value).toBe("rgdevment");
+ const offered = Array.from(document.querySelectorAll("#signed-before option")).map(
+ (one) => (one as HTMLOptionElement).value,
+ );
+ expect(offered).toEqual(["mario"]);
+ });
+
+ it("asks in plain words when the alias changes, and only then signs the older ones", async () => {
+ signing.alias = null;
+ signing.mine = 243;
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
+ await ready();
+
+ const field = await screen.findByRole("textbox", { name: /^alias$/i });
+ await userEvent.type(field, "rgdevment");
+ fireEvent.blur(field);
+
+ await screen.findByText("You now sign as rgdevment");
+ await screen.findByText(
+ "What was already written keeps the signature it had. Do you want the 243 older documents to carry this alias instead?",
+ );
+ await screen.findByText("What arrived from somebody else keeps their name, always.");
+
+ await userEvent.click(screen.getByRole("button", { name: /only the new ones/i }));
+ expect(sent("sign_the_rest")).toHaveLength(0);
+
+ await userEvent.click(await screen.findByRole("button", { name: /sign the older ones/i }));
+ await userEvent.click(await screen.findByRole("button", { name: /change them all/i }));
+
+ await waitFor(() => expect(sent("sign_the_rest")).toHaveLength(1));
+ await screen.findByText("243 documents signed");
+ });
+
+ it("says nothing about signing the rest while there is nothing to sign", async () => {
+ signing.alias = "rgdevment";
+ signing.mine = 0;
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
+ await ready();
+ await screen.findByRole("textbox", { name: /^alias$/i });
+
+ expect(screen.queryByRole("button", { name: /sign the older ones/i })).toBeNull();
+ });
+
+ it("takes an emptied alias as leaving it unsigned", async () => {
+ signing.alias = "rgdevment";
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
+ await ready();
+
+ const field = await screen.findByRole("textbox", { name: /^alias$/i });
+ await waitFor(() => expect((field as HTMLInputElement).value).toBe("rgdevment"));
+ await userEvent.clear(field);
+ fireEvent.blur(field);
+
+ await waitFor(() => expect(sent("sign")).toHaveLength(1));
+ expect(sent("sign")[0].args.alias).toBeUndefined();
+ });
+
it("offers the command line, and says what to do next", async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await ready();
expect(await screen.findByText(/cannot find it yet/i)).toBeTruthy();
@@ -1444,7 +2041,15 @@ describe("the first-run assistant", () => {
it("takes it back off when asked", async () => {
standing.withinReach = true;
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await ready();
expect(await screen.findByText(/already finds/i)).toBeTruthy();
@@ -1455,7 +2060,15 @@ describe("the first-run assistant", () => {
it("says nothing when there is no command line to offer", async () => {
standing.shipped = false;
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await ready();
expect(screen.queryByRole("switch", { name: /make it reachable/i })).toBeNull();
@@ -1464,7 +2077,15 @@ describe("the first-run assistant", () => {
describe("the report a bug gets attached to", () => {
const upkeep = async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
};
@@ -1511,7 +2132,15 @@ describe("the command line on a Mac", () => {
it("says so when the link lands where no shell looks", async () => {
standing.withinReach = true;
standing.onPath = false;
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await ready();
expect(await screen.findByText(/no shell looks in that folder/i)).toBeTruthy();
@@ -1522,7 +2151,15 @@ describe("the command line on a Mac", () => {
it("stays quiet where the folder is already searched", async () => {
standing.withinReach = true;
standing.onPath = true;
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await ready();
await screen.findByText(/already finds/i);
@@ -1532,7 +2169,15 @@ describe("the command line on a Mac", () => {
describe("opening with the machine", () => {
const notices = async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await ready();
};
@@ -1601,7 +2246,15 @@ describe("documents on disk the log does not name", () => {
};
const opened = async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
await userEvent.click(screen.getByRole("button", { name: /^review$/i }));
@@ -1646,7 +2299,15 @@ describe("documents on disk the log does not name", () => {
describe("looking for an update without waiting for tomorrow", () => {
const openTab = async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await ready();
};
@@ -1699,7 +2360,15 @@ describe("looking for an update without waiting for tomorrow", () => {
describe("letting an assistant file work here", () => {
const openTab = async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await userEvent.click(screen.getByRole("tab", { name: /assistants/i }));
};
@@ -1819,7 +2488,15 @@ describe("documents the log names with no file", () => {
};
const opened = async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
await userEvent.click(screen.getByRole("button", { name: /^review$/i }));
@@ -1852,7 +2529,15 @@ describe("documents the log names with no file", () => {
describe("taking every loose attachment out at once", () => {
const opened = async () => {
- render( {}} onChanged={() => {}} onDoc={() => {}} />);
+ render(
+ {}}
+ onUnpack={() => {}}
+ onGreet={() => {}}
+ onChanged={() => {}}
+ onDoc={() => {}}
+ />,
+ );
await data();
await go(/maintenance/i);
await userEvent.click(screen.getByRole("button", { name: /^review$/i }));
diff --git a/app/src/tests/lifecycle.test.tsx b/app/src/tests/lifecycle.test.tsx
index 53ba9eb..3b4e96a 100644
--- a/app/src/tests/lifecycle.test.tsx
+++ b/app/src/tests/lifecycle.test.tsx
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "../App";
import type { Papers } from "../core";
-import { t } from "../locales";
+import { fill, t } from "../locales";
import Docs from "../ui/Docs";
import Tree from "../ui/Tree";
@@ -34,6 +34,19 @@ const store = vi.hoisted(() => ({
const picked = vi.hoisted(() => ({ path: Promise.resolve(null as string | null) }));
+const taking = vi.hoisted(() => ({ files: 0, missed: 0, left: 0 }));
+
+const parcel = vi.hoisted(() => ({
+ packed: [] as { which: string[]; into: string }[],
+ docs: 0,
+ pages: 0,
+ folders: 0,
+ joined: 0,
+ files: 0,
+ missed: 0,
+ left: 0,
+}));
+
const carrier = vi.hoisted(() => ({ made: 0, asked: 0 }));
const ipc = vi.hoisted(() => ({
@@ -74,6 +87,7 @@ vi.mock("@tauri-apps/api/window", () => ({
vi.mock("@tauri-apps/plugin-dialog", () => ({
ask: () => Promise.resolve(true),
open: () => picked.path,
+ save: () => picked.path,
}));
vi.mock("@tauri-apps/plugin-clipboard-manager", () => ({
@@ -180,7 +194,36 @@ function backend(cmd: string, args: Record): Promise {
return Promise.resolve(null);
}
case "doc_export":
- return Promise.resolve(0);
+ return Promise.resolve({ ...taking });
+ case "docs_pack":
+ parcel.packed.push({ which: args.which as string[], into: String(args.into) });
+ return Promise.resolve({
+ docs: parcel.docs,
+ pages: parcel.pages,
+ folders: parcel.folders,
+ files: parcel.files,
+ missed: parcel.missed,
+ left: parcel.left,
+ });
+ case "docs_take_out":
+ parcel.packed.push({ which: args.which as string[], into: String(args.into) });
+ return Promise.resolve({
+ docs: parcel.docs,
+ pages: parcel.pages,
+ folders: parcel.folders,
+ files: parcel.files,
+ missed: parcel.missed,
+ left: parcel.left,
+ });
+ case "docs_unpack":
+ return Promise.resolve({
+ docs: parcel.docs,
+ pages: parcel.pages,
+ folders: parcel.folders,
+ joined: parcel.joined,
+ files: parcel.files,
+ missed: parcel.missed,
+ });
case "folder_rename": {
const folder = store.folders.find((one) => one.id === args.id);
if (folder) folder.name = String(args.name);
@@ -222,6 +265,17 @@ beforeEach(() => {
store.copied = [];
store.seq = 0;
picked.path = Promise.resolve(null);
+ taking.files = 0;
+ taking.missed = 0;
+ taking.left = 0;
+ parcel.packed = [];
+ parcel.docs = 0;
+ parcel.pages = 0;
+ parcel.folders = 0;
+ parcel.joined = 0;
+ parcel.files = 0;
+ parcel.missed = 0;
+ parcel.left = 0;
carrier.made = 0;
carrier.asked = 0;
ipc.answer = backend;
@@ -613,6 +667,142 @@ describe("what the menus reach for outside the tree", () => {
await waitFor(() => expect(screen.getByText(t("takenOutAlone"))).toBeTruthy());
});
+ it("packs everything written into one parcel, and asks for none of it by name", async () => {
+ seedDoc({ title: "Acta" });
+ seedDoc({ title: "Otra" });
+ picked.path = Promise.resolve("D:/salida/tisty.tistyx");
+ parcel.docs = 2;
+ await boot();
+
+ await userEvent.click(screen.getByRole("button", { name: t("docsActions") }));
+ await userEvent.click(await screen.findByRole("menuitem", { name: t("packAll") }));
+
+ await waitFor(() => expect(parcel.packed).toHaveLength(1));
+ expect(parcel.packed[0].which).toEqual([]);
+ await waitFor(() => expect(screen.getByText(fill("packed", "2"))).toBeTruthy());
+ });
+
+ it("writes everything out as plain markdown, saying how many folders it stood up", async () => {
+ seedDoc({ title: "Acta" });
+ picked.path = Promise.resolve("D:/salida");
+ parcel.docs = 5;
+ parcel.folders = 2;
+ await boot();
+
+ await userEvent.click(screen.getByRole("button", { name: t("docsActions") }));
+ await userEvent.click(await screen.findByRole("menuitem", { name: t("takeOutAll") }));
+
+ await waitFor(() => expect(parcel.packed).toHaveLength(1));
+ expect(parcel.packed[0].which).toEqual([]);
+ await waitFor(() => expect(screen.getByText(fill("tookOutAll", "5", "2"))).toBeTruthy());
+ });
+
+ it("says it is working, and how far along, rather than going quiet for minutes", async () => {
+ seedDoc({ title: "Acta" });
+ picked.path = Promise.resolve("D:/salida/tisty.tistyx");
+ parcel.docs = 1;
+ let held: (packed: unknown) => void = () => {};
+ const waiting = new Promise((settle) => {
+ held = settle;
+ });
+ const backend = ipc.answer;
+ ipc.answer = (cmd, args) => (cmd === "docs_pack" ? waiting : backend(cmd, args));
+ await boot();
+
+ await userEvent.click(screen.getByRole("button", { name: t("docsActions") }));
+ await userEvent.click(await screen.findByRole("menuitem", { name: t("packAll") }));
+
+ await waitFor(() => expect(screen.getByText(t("aWhileYet"))).toBeTruthy());
+ expect(screen.getByText(fill("packingOn", "").trim())).toBeTruthy();
+
+ held({ docs: 1, pages: 0, folders: 0, files: 0, missed: 0, left: 0 });
+ await waitFor(() => expect(screen.queryByText(t("aWhileYet"))).toBeNull());
+ });
+
+ it("packs one document by name when the parcel was asked for from its row", async () => {
+ const doc = seedDoc({ title: "Acta" });
+ picked.path = Promise.resolve("D:/salida/Acta.tistyx");
+ parcel.docs = 1;
+ await boot();
+
+ await chooseFor("Acta", t("packIt"));
+
+ await waitFor(() => expect(parcel.packed).toHaveLength(1));
+ expect(parcel.packed[0].which).toEqual([doc.file]);
+ });
+
+ it("counts the same going out as coming in, pages included", async () => {
+ seedDoc({ title: "Acta" });
+ picked.path = Promise.resolve("D:/salida/tisty.tistyx");
+ parcel.docs = 1;
+ parcel.pages = 10;
+ await boot();
+
+ await userEvent.click(screen.getByRole("button", { name: t("docsActions") }));
+ await userEvent.click(await screen.findByRole("menuitem", { name: t("packAll") }));
+
+ await waitFor(() => expect(screen.getByText(fill("packed", "11"))).toBeTruthy());
+ });
+
+ it("still says how many went out when something was left behind", async () => {
+ seedDoc({ title: "Acta" });
+ picked.path = Promise.resolve("D:/salida/tisty.tistyx");
+ parcel.docs = 400;
+ parcel.left = 1;
+ await boot();
+
+ await userEvent.click(screen.getByRole("button", { name: t("docsActions") }));
+ await userEvent.click(await screen.findByRole("menuitem", { name: t("packAll") }));
+
+ await waitFor(() => expect(screen.getByText(fill("packedLess", "400", "1"))).toBeTruthy());
+ });
+
+ it("says nothing came in rather than that the parcel was empty", async () => {
+ picked.path = Promise.resolve("D:/entrada/roto.tistyx");
+ parcel.missed = 7;
+ await boot();
+
+ await userEvent.click(screen.getByRole("button", { name: t("docsActions") }));
+ await userEvent.click(await screen.findByRole("menuitem", { name: t("unpackIt") }));
+
+ await waitFor(() => expect(screen.getByText(fill("landedNoneOfIt", "7"))).toBeTruthy());
+ });
+
+ it("says what came in when a parcel is taken in, folders and all", async () => {
+ picked.path = Promise.resolve("D:/entrada/tisty.tistyx");
+ parcel.docs = 4;
+ parcel.pages = 2;
+ parcel.folders = 3;
+ await boot();
+
+ await userEvent.click(screen.getByRole("button", { name: t("docsActions") }));
+ await userEvent.click(await screen.findByRole("menuitem", { name: t("unpackIt") }));
+
+ await waitFor(() => expect(screen.getByText(fill("landedIn", "6", "3"))).toBeTruthy());
+ });
+
+ it("says so plainly when the parcel held no documents at all", async () => {
+ picked.path = Promise.resolve("D:/entrada/vacio.tistyx");
+ await boot();
+
+ await userEvent.click(screen.getByRole("button", { name: t("docsActions") }));
+ await userEvent.click(await screen.findByRole("menuitem", { name: t("unpackIt") }));
+
+ await waitFor(() => expect(screen.getByText(t("landedNone"))).toBeTruthy());
+ });
+
+ it("says which files it points at were not there to take", async () => {
+ seedDoc({ title: "Acta" });
+ picked.path = Promise.resolve("D:/salida");
+ taking.files = 2;
+ taking.left = 3;
+ await boot();
+
+ await chooseFor("Acta", t("takeOut"));
+
+ await waitFor(() => expect(screen.getByText(fill("takenLesser", "3"))).toBeTruthy());
+ });
+
it("says nothing at all when the export was called off", async () => {
seedDoc({ title: "Acta" });
await boot();
diff --git a/app/src/ui/Beside.tsx b/app/src/ui/Beside.tsx
index c2fa3a8..a24125d 100644
--- a/app/src/ui/Beside.tsx
+++ b/app/src/ui/Beside.tsx
@@ -167,6 +167,24 @@ export default function Beside({
{t("aboutPaper")}
+ {facts?.author && (
+
+
{t("paperAuthor")}
+ {facts.author}
+
+ )}
+ {facts?.born && (
+
+
{t("paperBorn")}
+ {facts.born}
+
+ )}
+ {facts?.editor && (
+
+
{t("paperEditor")}
+ {facts.editor}
+
+ )}
{t("paperMade")}
{dated(facts?.made ?? null)}
diff --git a/app/src/ui/Docs.tsx b/app/src/ui/Docs.tsx
index caba5b2..70f11c2 100644
--- a/app/src/ui/Docs.tsx
+++ b/app/src/ui/Docs.tsx
@@ -6,7 +6,9 @@ import {
attachExport,
attached,
convertPaper,
+ docAway,
docExport,
+ docFacts,
docLock,
docOrder,
docRead,
@@ -18,6 +20,7 @@ import {
type Paper,
roomy,
} from "../core";
+import { stamped } from "../format";
import { frail } from "../frail";
import { fill, t } from "../locales";
import { filed, named, pagesOf, under } from "../paging";
@@ -26,6 +29,7 @@ import { saidPlainly } from "../refusal";
import { busy, holds, queued } from "../saving";
import Beside, { trailed } from "./Beside";
import Contents from "./Contents";
+import Modal from "./Modal";
import Ribbon, { Onward } from "./Ribbon";
import type { Block } from "./Slash";
import { clearOfChrome } from "./WindowChrome";
@@ -86,6 +90,8 @@ interface Props {
const tailless = (said: string): string => said.replace(/\n+$/, "");
+const dated = (when: number): string => stamped(new Date(when * 1000).toISOString());
+
export default function Docs({
open: asked,
known,
@@ -121,6 +127,8 @@ export default function Docs({
const [sized, setSized] = useState
>(leaves);
const [making, setMaking] = useState(false);
const [seeing, setSeeing] = useState(null);
+ const [pdfAsked, setPdfAsked] = useState(false);
+ const [signing, setSigning] = useState(false);
const giving = useRef<(() => unknown) | null>(null);
const putting = useRef<((page: Filed) => void) | null>(null);
const handed = useCallback((read: () => unknown) => {
@@ -323,7 +331,8 @@ export default function Docs({
};
const own = filed(known, open?.file);
- const bolted = Boolean(own?.locked);
+ const shelved = Boolean(own?.archived);
+ const bolted = Boolean(own?.locked) || shelved;
const stood = useRef(new Map());
const from = useRef<{ doc: string; page: string } | null>(null);
const seek = own?.file ? stood.current.get(own.file) : undefined;
@@ -367,7 +376,7 @@ export default function Docs({
tag.textContent = `@page { size: ${PAGE[leaf]}; margin: 22mm 20mm; }`;
}, [leaf]);
- const blobOf = async (): Promise => {
+ const blobOf = async (signed?: string): Promise => {
const read = giving.current;
if (!open || !read) return null;
const [{ pdf }, { Papered, registered }, { fetched, shapesOf }] = await Promise.all([
@@ -392,14 +401,26 @@ export default function Docs({
(one) => fetched(shapesOf(one), attached, at),
),
);
- return pdf( ).toBlob();
+ return pdf( ).toBlob();
+ };
+
+ const signature = async (file: string): Promise => {
+ const facts = await docFacts(file).catch(() => null);
+ if (!facts) return undefined;
+ const said = [
+ facts.author ? fill("pdfBy", facts.author) : "",
+ facts.editor ? fill("pdfEditedBy", facts.editor) : "",
+ facts.made ? fill("pdfMade", dated(facts.made)) : "",
+ facts.wrote ? fill("pdfWrote", dated(facts.wrote)) : "",
+ ].filter(Boolean);
+ return said.length ? said.join(" · ") : undefined;
};
const preview = async () => {
- if (making) return;
+ if (!open || making) return;
setMaking(true);
try {
- const blob = await blobOf();
+ const blob = await blobOf(signing ? await signature(open.file) : undefined);
if (blob) setSeeing(URL.createObjectURL(blob));
} catch (e) {
onError(saidPlainly(e));
@@ -428,9 +449,17 @@ export default function Docs({
const toPdf = async () => {
if (!open || making) return;
+ setSigning(false);
+ setPdfAsked(true);
+ };
+
+ const madePdf = async (signed: boolean) => {
+ if (!open || making) return;
+ setPdfAsked(false);
setMaking(true);
try {
- const blob = await blobOf();
+ const said = signed ? await signature(open.file) : undefined;
+ const blob = await blobOf(said);
if (!blob) return;
const where = await intoFile({
defaultPath: `${open.title || t("untitledDoc")}.pdf`,
@@ -626,7 +655,26 @@ export default function Docs({
)}
- {bolted && open && (
+ {shelved && open && (
+
+ {t("docShelved")}
+
+ docAway(own?.id ?? "", false)
+ .then(() => onKept({ id: open.id, title: open.title }))
+ .catch((e) => onError(saidPlainly(e)))
+ }
+ className="rounded-[7px] border border-line px-2 py-0.5 text-[11.5px] hover:bg-hover"
+ >
+ {t("bringBack")}
+
+
+ )}
+ {bolted && !shelved && open && (
)}
+ {pdfAsked && open && (
+ setPdfAsked(false)}>
+ {t("pdfSignWhy")}
+
+ setSigning(e.target.checked)}
+ className="h-3.5 w-3.5 accent-accent"
+ />
+ {t("pdfSign")}
+
+
+ setPdfAsked(false)}
+ className="rounded-lg px-3 py-1.5 text-faint hover:text-ink"
+ >
+ {t("cancel")}
+
+ madePdf(signing)}
+ className="cursor-pointer rounded-lg bg-accent px-3.5 py-1.5 text-bg"
+ >
+ {t("toPdfDo")}
+
+
+
+ )}
+
{beside && open && (
{
pick({ directory: true })
.then((at) => (typeof at === "string" ? docExport(open.file, at) : null))
+ .then((took) => {
+ if (took === null) return;
+ if (took.missed > 0) {
+ onError(
+ took.missed === 1 ? t("takenShort") : fill("takenShorter", String(took.missed)),
+ );
+ } else if (took.left > 0) {
+ onError(
+ took.left === 1 ? t("takenLess") : fill("takenLesser", String(took.left)),
+ );
+ }
+ })
.catch((e) => onError(saidPlainly(e)));
}}
onShut={() => setShown(false)}
diff --git a/app/src/ui/Keeping.tsx b/app/src/ui/Keeping.tsx
index 3a8eaea..22f71cf 100644
--- a/app/src/ui/Keeping.tsx
+++ b/app/src/ui/Keeping.tsx
@@ -1,9 +1,10 @@
import { listen } from "@tauri-apps/api/event";
import { ask, open, save } from "@tauri-apps/plugin-dialog";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import {
type About,
type Agent,
+ ALIAS_AT_MOST,
type Astray,
about,
agentState,
@@ -49,6 +50,9 @@ import {
type Stray,
seenAgents,
shortcut,
+ sign,
+ signed,
+ signTheRest,
stopFreeing,
syncKin,
syncNow,
@@ -104,6 +108,8 @@ type Which =
| "store"
| "brittle"
| "greet"
+ | "signing"
+ | "parcel"
| "tongue";
type Word = { card: Which; text: string };
type Tab = "general" | "data" | "agents" | "upkeep";
@@ -116,13 +122,15 @@ const TABS: { key: Tab; label: Parameters[0] }[] = [
];
interface Props {
+ onPack: () => void;
+ onUnpack: () => void;
onChanged: () => void;
onGreet: () => void;
onDoc: (paper: string) => void;
greeted?: number;
}
-export default function Keeping({ onChanged, onGreet, onDoc, greeted }: Props) {
+export default function Keeping({ onPack, onUnpack, onChanged, onGreet, onDoc, greeted }: Props) {
const [tab, setTab] = useState("general");
const [agent, setAgent] = useState(null);
const [agents, setAgents] = useState(null);
@@ -152,9 +160,23 @@ export default function Keeping({ onChanged, onGreet, onDoc, greeted }: Props) {
const [said, setSaid] = useState();
const [trouble, setTrouble] = useState();
const [told, setTold] = useState({ names: false, paths: false, logs: true });
+ const [alias, setAlias] = useState("");
+ const signed_as = useRef("");
+ const [aliases, setAliases] = useState([]);
+ const [mine, setMine] = useState(0);
+ const [asking, setAsking] = useState(null);
+ const before = aliases.filter((one) => one !== alias);
const [paper, setPaper] = useState(null);
const look = useCallback(() => {
+ signed()
+ .then((one) => {
+ setAlias(one.alias ?? "");
+ signed_as.current = one.alias ?? "";
+ setAliases(one.before);
+ setMine(one.mine);
+ })
+ .catch(() => {});
syncState()
.then(setState)
.catch((e) => setTrouble({ card: "sync", text: saidPlainly(e) }));
@@ -531,6 +553,37 @@ export default function Keeping({ onChanged, onGreet, onDoc, greeted }: Props) {
return (
+ {asking && (
+ setAsking(null)}>
+
+ {fill("aliasRestAsk", String(mine))}
+
+ {t("aliasRestNever")}
+
+ setAsking(null)}
+ className="rounded-lg px-3 py-1.5 text-faint hover:text-ink"
+ >
+ {t("aliasRestNo")}
+
+ {
+ setAsking(null);
+ run("signing", signTheRest(), (many) => {
+ setMine(0);
+ setSaid({ card: "signing", text: fill("aliasRestDone", String(many)) });
+ });
+ }}
+ className="cursor-pointer rounded-lg bg-accent px-3.5 py-1.5 text-bg disabled:opacity-60"
+ >
+ {t("aliasRestYes")}
+
+
+
+ )}
{picking && (
setPicking(false)}>
{t("keepersWhy")}
@@ -654,6 +707,66 @@ export default function Keeping({ onChanged, onGreet, onDoc, greeted }: Props) {
/>
+
+
+
{kept &&
@@ -1002,6 +1115,42 @@ export default function Keeping({ onChanged, onGreet, onDoc, greeted }: Props) {
{t("attachBig")}
+
+
+
+
+ {t("packAllDo")}
+
+
+
+
+ {t("unpackDo")}
+
+
+
+
{state.backsUp && (
<>
@@ -1735,6 +1884,26 @@ function Band({ label }: { label: string }) {
);
}
+function Ask({ said }: { said: string }) {
+ return (
+
+
+ ?
+
+
+ {said}
+
+
+ );
+}
+
function Line({
title,
why,
@@ -1744,7 +1913,7 @@ function Line({
children,
more,
}: {
- title: string;
+ title: React.ReactNode;
why?: React.ReactNode;
which: Which;
said?: Word;
@@ -1824,6 +1993,8 @@ interface CardProps {
const NAMED: Record[0]> = {
sync: "syncing",
+ signing: "alias",
+ parcel: "bandParcels",
backup: "backup",
restore: "restoreTitle",
review: "review",
diff --git a/app/src/ui/Tree.tsx b/app/src/ui/Tree.tsx
index d1f8310..9e8ab27 100644
--- a/app/src/ui/Tree.tsx
+++ b/app/src/ui/Tree.tsx
@@ -445,6 +445,14 @@ export default function Tree({
⚠
)}
+ {doc.guest && (
+
+ {doc.guest}
+
+ )}
{pages.length > 0 && (
{pages.length === 1 ? t("pageHeld") : fill("pagesHeld", String(pages.length))}
diff --git a/app/src/ui/Welcome.tsx b/app/src/ui/Welcome.tsx
index 18f65cd..ecd798f 100644
--- a/app/src/ui/Welcome.tsx
+++ b/app/src/ui/Welcome.tsx
@@ -1,5 +1,14 @@
import { useState } from "react";
-import { guide, keepClosing, keepLocale, sowLists, syncNow, wakeFor } from "../core";
+import {
+ ALIAS_AT_MOST,
+ guide,
+ keepClosing,
+ keepLocale,
+ sign,
+ sowLists,
+ syncNow,
+ wakeFor,
+} from "../core";
import { adopt, fill, t } from "../locales";
import { saidPlainly } from "../refusal";
import Keepers from "./Keepers";
@@ -9,9 +18,9 @@ interface Props {
onDone: (paper?: string) => void;
}
-type Step = "tongue" | "copies";
+type Step = "tongue" | "signing" | "copies";
-const STEPS: Step[] = ["tongue", "copies"];
+const STEPS: Step[] = ["tongue", "signing", "copies"];
const TONGUES = [
{ code: "es", name: "Español" },
@@ -52,6 +61,7 @@ export default function Welcome({ onDone }: Props) {
const [busy, setBusy] = useState(false);
const [trouble, setTrouble] = useState();
const [tongue, setTongue] = useState();
+ const [alias, setAlias] = useState("");
const [deciding, setDeciding] = useState(false);
const at = STEPS.indexOf(step);
@@ -63,12 +73,22 @@ export default function Welcome({ onDone }: Props) {
.then(() => adopt(code))
.then(() => {
setTongue(code);
- setStep("copies");
+ setStep("signing");
})
.catch((e) => setTrouble(saidPlainly(e)))
.finally(() => setBusy(false));
};
+ const signAs = () => {
+ const said = alias.trim();
+ setBusy(true);
+ setTrouble(undefined);
+ (said ? sign(said) : Promise.resolve(null))
+ .then(() => setStep("copies"))
+ .catch((e) => setTrouble(saidPlainly(e)))
+ .finally(() => setBusy(false));
+ };
+
const leave = (at?: string) => {
setBusy(true);
setTrouble(undefined);
@@ -84,7 +104,16 @@ export default function Welcome({ onDone }: Props) {
};
return (
- 0}>
+
- {step === "tongue" ? t("welcomeTongueWhy") : t("keepersWhy")}
+ {step === "tongue"
+ ? t("welcomeTongueWhy")
+ : step === "signing"
+ ? t("welcomeSigningWhy")
+ : t("keepersWhy")}
- {step === "tongue" ? (
+ {step === "signing" ? (
+ <>
+
{t("welcomeSigningHow")}
+
+ setAlias(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && signAs()}
+ className="min-w-0 flex-1 rounded-lg border border-line bg-bg px-3 py-2 text-[13px] disabled:opacity-60"
+ />
+
+ {t("welcomeSigned")}
+
+
+
{t("welcomeSigningNote")}
+ >
+ ) : step === "tongue" ? (
TONGUES.map((one) => (
setStep("tongue")}
+ onClick={() => setStep("signing")}
className="text-faint hover:text-ink disabled:opacity-60"
>
{t("welcomeBack")}
)}
+ {step === "signing" && (
+ <>
+ setStep("tongue")}
+ className="text-faint hover:text-ink disabled:opacity-60"
+ >
+ {t("welcomeBack")}
+
+ setStep("copies")}
+ className="ml-auto text-faint hover:text-ink disabled:opacity-60"
+ >
+ {t("welcomeNotNow")}
+
+ >
+ )}
{step === "tongue" && (
setStep("copies")}
+ onClick={() => setStep("signing")}
className="ml-auto text-faint hover:text-ink disabled:opacity-60"
>
{t("welcomeNext")}
diff --git a/app/src/ui/paper.tsx b/app/src/ui/paper.tsx
index 78394ee..b092c8b 100644
--- a/app/src/ui/paper.tsx
+++ b/app/src/ui/paper.tsx
@@ -151,6 +151,13 @@ const sheet = StyleSheet.create({
cardLine: { height: 1.4, backgroundColor: "#c4c4c8", marginVertical: 1.4 },
cardName: { fontSize: 10.5, fontWeight: 700 },
cardSaid: { fontSize: 9, color: "#71717a", marginTop: 2 },
+ signed: {
+ marginTop: 18,
+ paddingTop: 8,
+ borderTop: "0.5pt solid #e4e4e7",
+ fontSize: 8.5,
+ color: "#a1a1aa",
+ },
foot: {
position: "absolute",
bottom: 24,
@@ -409,7 +416,15 @@ const shaped = (one: Shape, at: number, room: number) => {
}
};
-export const Papered = ({ sheets, leaf }: { sheets: Shape[][]; leaf: Paper }) => {
+export const Papered = ({
+ sheets,
+ leaf,
+ signed,
+}: {
+ sheets: Shape[][];
+ leaf: Paper;
+ signed?: string;
+}) => {
const size = SIZES[leaf];
return (
@@ -417,6 +432,7 @@ export const Papered = ({ sheets, leaf }: { sheets: Shape[][]; leaf: Paper }) =>
{sheets.map((shapes, sheet_at) => (
{shapes.map((one, at) => shaped(one, at, size[0] - MARGIN * 2))}
+ {signed && sheet_at === sheets.length - 1 && {signed} }
anyhow::Result> {
ops.extend(made.into_iter().enumerate().map(|(n, one)| Op::DocAdd {
id: ulid::Ulid::generate(),
d: DocAdd {
+ wrote: None,
+ guest: false,
+ made: None,
+ by: None,
page_of: None,
file: one.id,
order: format!("a{n}"),
@@ -300,6 +304,7 @@ fn papers(app: &App, lang: Lang) -> anyhow::Result> {
title: one.title,
bytes: None,
tags: Some(Vec::new()),
+ by: None,
}),
folder: (n >= 2).then_some(shelf),
},
diff --git a/crates/tisty-cli/src/cmd/org.rs b/crates/tisty-cli/src/cmd/org.rs
index 3494291..dca6002 100644
--- a/crates/tisty-cli/src/cmd/org.rs
+++ b/crates/tisty-cli/src/cmd/org.rs
@@ -177,7 +177,7 @@ pub fn tag(app: &mut App, action: Option, lang: Lang) -> anyhow::Resu
}
TagAction::Rename { old, new } => {
- let (old, new) = (parse_tag(&old)?, parse_tag(&new)?);
+ let (old, new) = (parse_tag(&old)?, fresh_tag(&new)?);
let ops = retag(app, &old, Some(new.clone()));
if ops.is_empty() {
return Ok(missing_tag(&old, lang));
@@ -243,6 +243,10 @@ fn parse_tag(raw: &str) -> anyhow::Result {
Ok(Tag::new(raw.trim_start_matches('@'))?)
}
+fn fresh_tag(raw: &str) -> anyhow::Result {
+ Ok(Tag::written(raw.trim_start_matches('@'))?)
+}
+
fn missing_tag(tag: &Tag, lang: Lang) -> ExitCode {
eprintln!("{}", lang.fill("no-such-tag", &[("tag", tag.as_str())]));
ExitCode::from(EXIT_NOT_FOUND)
diff --git a/crates/tisty-cli/src/cmd/task.rs b/crates/tisty-cli/src/cmd/task.rs
index f9bf4da..d98a5ac 100644
--- a/crates/tisty-cli/src/cmd/task.rs
+++ b/crates/tisty-cli/src/cmd/task.rs
@@ -338,7 +338,7 @@ fn merged_tags(
let mut tags = app.state.tasks[&id].tags.clone();
for raw in add {
- let tag = Tag::new(raw.trim_start_matches('@'))?;
+ let tag = Tag::written(raw.trim_start_matches('@'))?;
if !tags.contains(&tag) {
tags.push(tag);
}
diff --git a/crates/tisty-cli/src/mcp.rs b/crates/tisty-cli/src/mcp.rs
index 1c17ad4..d734017 100644
--- a/crates/tisty-cli/src/mcp.rs
+++ b/crates/tisty-cli/src/mcp.rs
@@ -69,7 +69,7 @@ to consult. Writing one creates no task: if something has to happen, propose it.
what is written already and the folders it is kept in; you can make a folder and file documents \
into it, but you can never delete or rename one.
-A document can be locked, and a locked one is refused every write: not `write_doc`, not `append_doc`, not `edit_doc`, not `attach`, not hanging a page off it. Its pages are shut with it — `page_doc` neither hangs one off it nor takes one out — and a page is never locked on its own. Filing it in a folder and putting it away still work: what the lock guards is what the document says and what it holds. `docs` and `read_doc` both say so, so you can see it before you try. Only the person can unlock it, from the window — there is no tool for it here, on purpose. A lock is not the archive: an archived document is finished, a locked one is guarded.
+A document can be locked, and a locked one is refused every write: not `write_doc`, not `append_doc`, not `edit_doc`, not `attach`, not hanging a page off it. Its pages are shut with it — `page_doc` neither hangs one off it nor takes one out — and a page is never locked on its own. Filing it in a folder and putting it away still work: what the lock guards is what the document says and what it holds. `docs` and `read_doc` both say so, so you can see it before you try. Only the person can unlock it, from the window — there is no tool for it here, on purpose. A lock is not the archive, though neither one is written in: an archived document is finished, a locked one is guarded. Bring it back with `archive_doc` and it writes again; a lock only the person can lift, from the window.
A document can hold pages, and that is the only level there is: `write_doc` with `page_of` writes one under the document you name, and `page_doc` makes a document a page of another or takes it back out as a document of its own. A page belongs to one document and holds no pages itself, so naming a page as `page_of` is refused. It goes with its document into a folder, into the archive and out of existence — a page is part of what it belongs to, not a document filed beside it. Pages suit one long thing in parts: a book by chapters, a year of minutes.
@@ -486,7 +486,7 @@ const UNSETTLED: &str = " Where its pages sit could not be settled just now —
fn retold(state: &State, store: &mut Store, doc: &str, body: &str) -> Result<(), Refused> {
let mut told = state.settling(doc, body);
if let Some(kept) = state.docs.values().find(|one| one.file == doc) {
- let said = tisty_core::event::Said::of(body);
+ let said = tisty_core::event::Said::of(body).by(state.signed.alias.clone());
if said.news_for(kept) {
told.push(Op::DocSaid {
id: kept.id,
@@ -531,7 +531,7 @@ fn propose(paths: &Paths, args: &Value) -> Result {
let mut tags: Vec = Vec::new();
for one in listed(args, "tags")
.iter()
- .filter_map(|said| Tag::new(said).ok())
+ .filter_map(|said| Tag::written(said).ok())
{
if !tags.contains(&one) {
tags.push(one);
@@ -1273,6 +1273,10 @@ fn write_doc(paths: &Paths, args: &Value) -> Result {
if let Err(e) = store.append(Op::DocAdd {
id,
d: tisty_core::event::DocAdd {
+ wrote: None,
+ guest: false,
+ made: None,
+ by: state.signed.alias.clone(),
file: made.id.clone(),
order,
said: Some(tisty_core::event::Said::of(&body)),
@@ -2103,17 +2107,30 @@ fn export_doc(paths: &Paths, args: &Value) -> Result {
.iter()
.map(|one| one.file.clone())
.collect();
- let taken = tisty_core::docs::with_pages(paths.data(), &which, &pages, &into).map_err(hitch)?;
+ let beside = match tisty_core::Config::load_or_init(paths).map_err(hitch)?.sync {
+ Some(tisty_core::config::Sync::Folder(at)) => Some(at),
+ _ => None,
+ };
+ let taken =
+ tisty_core::docs::with_pages(paths.data(), &which, &pages, &into, beside.as_deref())
+ .map_err(hitch)?;
Ok(told(
format!(
- "Took {which} out to {} — its cover, {} page(s) and {} file(s) beside them{}. Nothing here changed: an export is a copy.",
+ "Took {which} out to {} — its cover, {} page(s) and {} file(s) beside them{}{}. Nothing here changed: an export is a copy.",
into.display(),
pages.len(),
taken.files,
match taken.missed {
0 => String::new(),
many => format!(", and {many} page(s) could not be read, so they are not there"),
+ },
+ match taken.left.len() {
+ 0 => String::new(),
+ many => format!(
+ ", and {many} file(s) it points at are not in the store, so they did not come along: {}",
+ taken.left.join(", ")
+ ),
}
),
json!({
@@ -2122,6 +2139,7 @@ fn export_doc(paths: &Paths, args: &Value) -> Result {
"pages_out": pages.len(),
"files": taken.files,
"missed": taken.missed,
+ "left_behind": taken.left,
"pages": pages,
}),
))
diff --git a/crates/tisty-core/src/attach.rs b/crates/tisty-core/src/attach.rs
index df9570f..1eb0f0b 100644
--- a/crates/tisty-core/src/attach.rs
+++ b/crates/tisty-core/src/attach.rs
@@ -840,6 +840,17 @@ fn decoded(said: &str) -> Option {
String::from_utf8(out).ok()
}
+pub fn found(reference: &str, root: &Path, also: Option<&Path>) -> Result {
+ let here = resolve(reference, root)?;
+ if here.is_file() {
+ return Ok(here);
+ }
+ match also.map(|beside| resolve(reference, beside)) {
+ Some(Ok(there)) if there.is_file() => Ok(there),
+ _ => Ok(here),
+ }
+}
+
pub fn resolve(reference: &str, root: &Path) -> Result {
let cleaned = reference.split(['?', '#']).next().unwrap_or("");
let refused = || Err(Error::OutsideTheStore(reference.to_string()));
diff --git a/crates/tisty-core/src/cache.rs b/crates/tisty-core/src/cache.rs
index 06f5878..5ef3ddb 100644
--- a/crates/tisty-core/src/cache.rs
+++ b/crates/tisty-core/src/cache.rs
@@ -68,6 +68,14 @@ impl Cache {
}
let mut state = State::default();
+ state.signed = self
+ .meta("signed")
+ .and_then(|said| serde_json::from_str(&said).ok())
+ .unwrap_or_default();
+ state.signed_before = self
+ .meta("signed_before")
+ .and_then(|said| serde_json::from_str(&said).ok())
+ .unwrap_or_default();
state.devices = self
.meta("devices")
.and_then(|said| serde_json::from_str(&said).ok())
@@ -221,10 +229,12 @@ impl Cache {
}
}
tx.execute(
- "INSERT OR REPLACE INTO meta VALUES ('schema', ?), ('fingerprint', ?), ('devices', ?), ('dropped', ?), ('retired', ?), ('shed', ?), ('agents', ?), ('assistants', ?), ('forebears', ?)",
+ "INSERT OR REPLACE INTO meta VALUES ('schema', ?), ('fingerprint', ?), ('signed', ?), ('signed_before', ?), ('devices', ?), ('dropped', ?), ('retired', ?), ('shed', ?), ('agents', ?), ('assistants', ?), ('forebears', ?)",
rusqlite::params![
SCHEMA.to_string(),
fingerprint,
+ serde_json::to_string(&state.signed).unwrap_or_default(),
+ serde_json::to_string(&state.signed_before).unwrap_or_default(),
serde_json::to_string(&state.devices).unwrap_or_default(),
serde_json::to_string(&state.dropped).unwrap_or_default(),
serde_json::to_string(&state.retired).unwrap_or_default(),
@@ -526,6 +536,7 @@ fn reached(
| crate::Op::DeviceJoin { .. }
| crate::Op::DeviceRemove { .. }
| crate::Op::AttachRetire { .. }
+ | crate::Op::Signed { .. }
// These reach the pages of a document, and a row at a time cannot say so.
| crate::Op::DocDelete { .. }
| crate::Op::DocArchive { .. }
@@ -820,6 +831,10 @@ mod tests {
.append(Op::DocAdd {
id,
d: crate::event::DocAdd {
+ wrote: None,
+ guest: false,
+ made: None,
+ by: None,
said: None,
file: file.into(),
order: "a0".into(),
@@ -864,6 +879,10 @@ mod tests {
.append(Op::DocAdd {
id: Ulid::generate(),
d: crate::event::DocAdd {
+ wrote: None,
+ guest: false,
+ made: None,
+ by: None,
said: None,
file: "a3f1-0001".into(),
order: "a0".into(),
diff --git a/crates/tisty-core/src/capture.rs b/crates/tisty-core/src/capture.rs
index a2f5ef5..6021ce3 100644
--- a/crates/tisty-core/src/capture.rs
+++ b/crates/tisty-core/src/capture.rs
@@ -87,7 +87,7 @@ pub fn plan(state: &State, draft: Draft) -> Result {
date: draft.date,
deadline: draft.deadline,
priority: draft.priority,
- tags: draft.tags,
+ tags: crate::tagging::worth_keeping(&draft.tags),
list,
repeat: draft.repeat,
source: draft.source,
diff --git a/crates/tisty-core/src/docs.rs b/crates/tisty-core/src/docs.rs
index f4b297e..a0136ea 100644
--- a/crates/tisty-core/src/docs.rs
+++ b/crates/tisty-core/src/docs.rs
@@ -642,25 +642,48 @@ pub fn read(root: &Path, id: &str) -> Result {
/// What came out, and what could not: a page missing from disk is left behind, and saying so
/// is the only way the person learns their book came out a chapter short.
-#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Taken {
pub files: usize,
pub missed: usize,
+ pub left: Vec,
}
pub fn exported(data: &Path, id: &str, into: &Path) -> Result {
- with_pages(data, id, &[], into)
+ with_pages(data, id, &[], into, None)
}
/// The pages travel with the document: a book exported by its cover alone is not the book.
-pub fn with_pages(data: &Path, id: &str, pages: &[String], into: &Path) -> Result {
+pub fn with_pages(
+ data: &Path,
+ id: &str,
+ pages: &[String],
+ into: &Path,
+ also: Option<&Path>,
+) -> Result {
+ laid_out_as(data, id, pages, into, None, also)
+}
+
+pub fn laid_out_as(
+ data: &Path,
+ id: &str,
+ pages: &[String],
+ into: &Path,
+ called: Option<&str>,
+ also: Option<&Path>,
+) -> Result {
if into.starts_with(data) || data.starts_with(into) {
return Err(Error::OutsideTheStore(into.display().to_string()));
}
let body = read(&data.join("docs"), id)?;
- let named = titled(&body);
- let named = spelled(if named.is_empty() { id } else { &named });
+ let named = match called {
+ Some(one) => one.to_string(),
+ None => {
+ let named = titled(&body);
+ spelled(if named.is_empty() { id } else { &named })
+ }
+ };
let folder = into.join(&named);
std::fs::create_dir_all(into)?;
std::fs::create_dir(&folder)?;
@@ -698,13 +721,19 @@ pub fn with_pages(data: &Path, id: &str, pages: &[String], into: &Path) -> Resul
&beside(&body),
&folder,
&format!("{named}.{EXTENSION}"),
+ also,
)?;
for (_, at, body) in &written {
- taken += laid_out(data, &beside(body), &folder, at)?;
+ let more = laid_out(data, &beside(body), &folder, at, also)?;
+ taken.files += more.files;
+ for one in more.left {
+ left_behind(&mut taken.left, one);
+ }
}
Ok(Taken {
- files: taken,
+ files: taken.files,
missed,
+ left: taken.left,
})
}
@@ -743,33 +772,58 @@ fn began(before: &str) -> Option {
open
}
-fn laid_out(data: &Path, body: &str, folder: &Path, named: &str) -> Result {
+fn left_behind(left: &mut Vec, one: String) {
+ if !left.contains(&one) {
+ left.push(one);
+ }
+}
+
+fn shelved<'a>(from: &'a Path, held: &Path, also: Option<&Path>) -> Option<&'a Path> {
+ from.strip_prefix(held)
+ .ok()
+ .or_else(|| also.and_then(|beside| from.strip_prefix(beside.join("attachments")).ok()))
+}
+
+fn laid_out(
+ data: &Path,
+ body: &str,
+ folder: &Path,
+ named: &str,
+ also: Option<&Path>,
+) -> Result {
write_atomic(&folder.join(named), body.as_bytes())?;
let held = data.join("attachments");
- let mut taken = 0;
+ let mut taken = Taken::default();
for one in crate::refs::extract(body).into_iter().map(|one| one.target) {
if !one.starts_with("attachments/") {
continue;
}
- let Ok(from) = crate::attach::resolve(&one, data) else {
+ let Ok(from) = crate::attach::found(&one, data, also) else {
+ left_behind(&mut taken.left, one);
continue;
};
- let Ok(rest) = from.strip_prefix(&held) else {
+ let Some(rest) = shelved(&from, &held, also) else {
continue;
};
+ if !from.is_file() {
+ left_behind(&mut taken.left, one);
+ continue;
+ }
let at = folder.join("attachments").join(rest);
if let Some(under) = at.parent() {
std::fs::create_dir_all(under)?;
}
if std::fs::copy(&from, &at).is_ok() {
- taken += 1;
+ taken.files += 1;
+ } else {
+ left_behind(&mut taken.left, one);
}
}
Ok(taken)
}
-fn spelled(said: &str) -> String {
+pub fn spelled(said: &str) -> String {
let flat: String = said
.chars()
.map(|c| {
@@ -3738,6 +3792,7 @@ despues
"mac0-0001",
&["mac0-0002".into(), "mac0-0003".into()],
out.path(),
+ None,
)
.unwrap();
@@ -3863,6 +3918,53 @@ despues
);
}
+ #[test]
+ fn a_file_that_is_not_in_the_store_is_named_rather_than_dropped_in_silence() {
+ let room = tempfile::tempdir().unwrap();
+ let data = room.path();
+ std::fs::create_dir_all(data.join("docs")).unwrap();
+ let shelf = data.join("attachments").join("ab");
+ std::fs::create_dir_all(&shelf).unwrap();
+ std::fs::write(shelf.join("foto-91f2ab00.png"), b"a picture").unwrap();
+ std::fs::write(
+ data.join("docs").join("mac0-0001.md"),
+ "# Minuta\n\n![una foto]()\n\n![un video]()",
+ )
+ .unwrap();
+
+ let out = tempfile::tempdir().unwrap();
+ let taken = exported(data, "mac0-0001", out.path()).unwrap();
+
+ assert_eq!(taken.files, 1);
+ assert_eq!(taken.left, ["attachments/6d/clip-da1d77da.mov"]);
+ assert!(
+ !out.path().join("Minuta/attachments/6d").exists(),
+ "it left an empty shelf where the file was not"
+ );
+ }
+
+ #[test]
+ fn a_file_named_by_a_page_and_by_its_document_is_only_missed_once() {
+ let room = tempfile::tempdir().unwrap();
+ let data = room.path();
+ std::fs::create_dir_all(data.join("docs")).unwrap();
+ std::fs::write(
+ data.join("docs").join("mac0-0001.md"),
+ "# Libro\n\n![un video]()",
+ )
+ .unwrap();
+ std::fs::write(
+ data.join("docs").join("mac0-0002.md"),
+ "# Capitulo\n\n![el mismo video]()",
+ )
+ .unwrap();
+
+ let out = tempfile::tempdir().unwrap();
+ let taken = with_pages(data, "mac0-0001", &["mac0-0002".into()], out.path(), None).unwrap();
+
+ assert_eq!(taken.left, ["attachments/6d/clip-da1d77da.mov"]);
+ }
+
#[test]
fn what_is_taken_out_is_named_after_the_document_and_not_after_its_file() {
let room = tempfile::tempdir().unwrap();
diff --git a/crates/tisty-core/src/event.rs b/crates/tisty-core/src/event.rs
index 3202d71..067cdff 100644
--- a/crates/tisty-core/src/event.rs
+++ b/crates/tisty-core/src/event.rs
@@ -1,14 +1,15 @@
mod op;
pub use op::{
- Body, DeviceKind, DocAdd, Filed, FolderAdd, KNOWN_OPS, ListAdd, LogAdd, LogEdit, Look, Name,
- Op, Said, StepAdd, StepRef, StepReorder, StepText, Stitch, TaskAdd, TaskMove, TaskPatch,
+ ALIAS_AT_MOST, Body, DeviceKind, DocAdd, Filed, FolderAdd, KNOWN_OPS, ListAdd, LogAdd, LogEdit,
+ Look, Name, Op, Said, Signature, StepAdd, StepRef, StepReorder, StepText, Stitch, TaskAdd,
+ TaskMove, TaskPatch,
};
use serde::{Deserialize, Serialize};
use ulid::Ulid;
-pub const SCHEMA_VERSION: u32 = 10;
+pub const SCHEMA_VERSION: u32 = 11;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
@@ -108,11 +109,13 @@ impl Event {
| Op::DocMove { id, .. }
| Op::DocSaid { id, .. }
| Op::DocDelete { id }
+ | Op::DocSigned { id, .. }
| Op::DocArchive { id }
| Op::DocUnarchive { id }
| Op::DocLock { id }
| Op::DocUnlock { id } => Some(*id),
Op::DeviceJoin { .. }
+ | Op::Signed { .. }
| Op::DeviceRemove { .. }
| Op::AttachRetire { .. }
| Op::StoresJoined { .. } => None,
diff --git a/crates/tisty-core/src/event/op.rs b/crates/tisty-core/src/event/op.rs
index 98e7a13..1586f2c 100644
--- a/crates/tisty-core/src/event/op.rs
+++ b/crates/tisty-core/src/event/op.rs
@@ -65,8 +65,10 @@ pub const KNOWN_OPS: &[&str] = &[
"doc.unarchive",
"doc.lock",
"doc.unlock",
+ "doc.signed",
"device.join",
"device.remove",
+ "person.signed",
"attach.retire",
"stores.joined",
];
@@ -159,6 +161,9 @@ pub enum Op {
#[serde(rename = "doc.unlock")]
DocUnlock { id: DocId },
+ #[serde(rename = "doc.signed")]
+ DocSigned { id: DocId, d: String },
+
#[serde(rename = "device.join")]
DeviceJoin {
d: DeviceId,
@@ -170,6 +175,9 @@ pub enum Op {
#[serde(rename = "device.remove")]
DeviceRemove { d: DeviceId },
+ #[serde(rename = "person.signed")]
+ Signed { d: Signature },
+
#[serde(rename = "attach.retire")]
AttachRetire { d: String },
@@ -218,7 +226,7 @@ impl Op {
}
pub fn is_optional(&self) -> bool {
- matches!(self, Op::DocSaid { .. })
+ matches!(self, Op::DocSaid { .. } | Op::Signed { .. })
}
pub fn about(self, id: TaskId) -> Self {
@@ -255,12 +263,14 @@ impl Op {
Op::DocAdd { d, .. } => Op::DocAdd { id, d },
Op::DocMove { d, .. } => Op::DocMove { id, d },
Op::DocSaid { d, .. } => Op::DocSaid { id, d },
+ Op::DocSigned { d, .. } => Op::DocSigned { id, d },
Op::DocDelete { .. } => Op::DocDelete { id },
Op::DocArchive { .. } => Op::DocArchive { id },
Op::DocUnarchive { .. } => Op::DocUnarchive { id },
Op::DocLock { .. } => Op::DocLock { id },
Op::DocUnlock { .. } => Op::DocUnlock { id },
Op::DeviceJoin { .. }
+ | Op::Signed { .. }
| Op::DeviceRemove { .. }
| Op::AttachRetire { .. }
| Op::StoresJoined { .. } => self,
@@ -317,6 +327,24 @@ impl Op {
d.name = one(d.name);
Op::ListRename { id, d }
}
+ Op::DocAdd { id, mut d } => {
+ d.by = maybe(d.by);
+ if let Some(said) = d.said.as_mut() {
+ said.by = maybe(said.by.take());
+ }
+ Op::DocAdd { id, d }
+ }
+ Op::DocSaid { id, mut d } => {
+ d.by = maybe(d.by);
+ Op::DocSaid { id, d }
+ }
+ Op::DocSigned { id, d } => Op::DocSigned { id, d: one(d) },
+ Op::Signed { mut d } => {
+ d.alias = maybe(d.alias);
+ d.name = maybe(d.name);
+ d.email = maybe(d.email);
+ Op::Signed { d }
+ }
plain => plain,
}
}
@@ -355,12 +383,14 @@ impl Op {
| Op::DocAdd { id, .. }
| Op::DocMove { id, .. }
| Op::DocSaid { id, .. }
+ | Op::DocSigned { id, .. }
| Op::DocDelete { id }
| Op::DocArchive { id }
| Op::DocUnarchive { id }
| Op::DocLock { id }
| Op::DocUnlock { id } => Some(*id),
Op::DeviceJoin { .. }
+ | Op::Signed { .. }
| Op::DeviceRemove { .. }
| Op::AttachRetire { .. }
| Op::StoresJoined { .. } => None,
@@ -545,6 +575,8 @@ pub struct Said {
/// that has none, and telling them apart is what keeps a sync from wiping them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tags: Option>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub by: Option,
}
impl Said {
@@ -553,9 +585,15 @@ impl Said {
title: crate::docs::titled(body),
bytes: Some(body.len() as u64),
tags: Some(crate::tagging::tags_in(body)),
+ by: None,
}
}
+ pub fn by(mut self, who: Option) -> Self {
+ self.by = who;
+ self
+ }
+
pub fn news_for(&self, kept: &crate::model::Kept) -> bool {
kept.title.as_deref() != Some(self.title.as_str())
|| kept.bytes != self.bytes
@@ -568,6 +606,14 @@ pub struct DocAdd {
pub file: String,
pub order: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
+ pub made: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub wrote: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub by: Option,
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub guest: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
pub said: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder: Option,
@@ -576,6 +622,20 @@ pub struct DocAdd {
pub page_of: Option,
}
+/// Kept whole so that a name or an address can be filled in later without the log learning a
+/// new shape: what is written today is the alias alone.
+pub const ALIAS_AT_MOST: usize = 40;
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Signature {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub alias: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub name: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub email: Option,
+}
+
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Filed {
#[serde(default, skip_serializing_if = "Option::is_none", with = "null_clears")]
diff --git a/crates/tisty-core/src/lib.rs b/crates/tisty-core/src/lib.rs
index 143b17b..64a13da 100644
--- a/crates/tisty-core/src/lib.rs
+++ b/crates/tisty-core/src/lib.rs
@@ -13,6 +13,7 @@ pub mod keepers;
pub mod merge;
pub mod model;
pub mod order;
+pub mod parcel;
pub mod paths;
pub mod refs;
pub mod series;
@@ -49,6 +50,12 @@ pub enum Error {
OutsideTheStore(String),
#[error("{0} does not hold what its name says it holds")]
NotForAnAgent(String),
+ #[error("{0} is not a Tisty parcel")]
+ NotAParcel(String),
+ #[error("that parcel was written by a newer Tisty (version {0})")]
+ ParcelNewer(u32),
+ #[error("there is nothing here to carry out")]
+ NothingToCarry,
#[error("that backup belongs to another store ({theirs})")]
OtherStore { theirs: String },
@@ -76,7 +83,9 @@ pub enum Error {
},
#[error("segment {number:06} of {device} is missing: that slice of history is not here")]
MissingSegment { number: usize, device: String },
- #[error("event schema version {0} is newer than this build understands")]
+ #[error(
+ "event schema version {0} is newer than this build understands: update Tisty on this machine before going on, or reading half of it would lose work"
+ )]
UnsupportedVersion(u32),
#[error("another tisty process is using this device's store")]
AlreadyRunning,
@@ -100,6 +109,9 @@ impl Error {
Error::Io(_) => "io",
Error::OutsideTheStore(_) => "outsideTheStore",
Error::NotForAnAgent(_) => "notForAnAgent",
+ Error::NotAParcel(_) => "notAParcel",
+ Error::ParcelNewer(_) => "parcelNewer",
+ Error::NothingToCarry => "nothingToCarry",
Error::OtherStore { .. } => "otherStore",
Error::TooBig => "tooBig",
Error::AttachmentTooBig { .. } => "attachmentTooBig",
diff --git a/crates/tisty-core/src/model/folder.rs b/crates/tisty-core/src/model/folder.rs
index df96c8f..dee3aff 100644
--- a/crates/tisty-core/src/model/folder.rs
+++ b/crates/tisty-core/src/model/folder.rs
@@ -49,6 +49,20 @@ pub struct Kept {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wrote: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
+ pub made: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub made_by: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub wrote_by: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub by: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub born_by: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub edited_by: Option,
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub guest: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
pub folder: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page_of: Option,
@@ -75,9 +89,15 @@ mod tests {
#[test]
fn a_document_with_no_folder_is_unfiled_rather_than_absent() {
let kept = Kept {
+ born_by: None,
+ guest: false,
title: None,
bytes: None,
wrote: None,
+ made: None,
+ made_by: None,
+ wrote_by: None,
+ by: None,
tags: Vec::new(),
id: Ulid::generate(),
file: "a3f1-0001".into(),
@@ -86,6 +106,7 @@ mod tests {
page_of: None,
archived: false,
locked: false,
+ edited_by: None,
};
let json = serde_json::to_string(&kept).unwrap();
diff --git a/crates/tisty-core/src/model/tag.rs b/crates/tisty-core/src/model/tag.rs
index e7fe025..1fb5fa0 100644
--- a/crates/tisty-core/src/model/tag.rs
+++ b/crates/tisty-core/src/model/tag.rs
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
-#[error("a tag needs at least one letter or digit")]
+#[error("a tag needs two characters, one of them a letter")]
pub struct InvalidTag;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
@@ -45,6 +45,13 @@ impl Tag {
self.0.chars().nth(1).is_some() && self.0.chars().any(char::is_alphabetic)
}
+ pub fn written(raw: &str) -> Result {
+ Self::new(raw)
+ .ok()
+ .filter(Self::worth_reading)
+ .ok_or(InvalidTag)
+ }
+
pub fn as_str(&self) -> &str {
&self.0
}
@@ -161,6 +168,15 @@ mod tests {
}
}
+ #[test]
+ fn what_is_written_now_answers_to_the_rule_of_now() {
+ for turned_away in ["1", "1234", "2026", "a", "x", "---", ""] {
+ assert_eq!(Tag::written(turned_away), Err(InvalidTag), "{turned_away}");
+ }
+ assert_eq!(Tag::written(" Legal ").unwrap().as_str(), "legal");
+ assert_eq!(Tag::written("b2b").unwrap().as_str(), "b2b");
+ }
+
#[test]
fn deserialisation_normalises() {
let tag: Tag = serde_json::from_str(r#"" Work ""#).unwrap();
diff --git a/crates/tisty-core/src/parcel.rs b/crates/tisty-core/src/parcel.rs
new file mode 100644
index 0000000..c9b40f6
--- /dev/null
+++ b/crates/tisty-core/src/parcel.rs
@@ -0,0 +1,910 @@
+use std::collections::{BTreeMap, BTreeSet};
+use std::io::{Read, Seek};
+use std::path::{Component, Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+use ulid::Ulid;
+
+use crate::{
+ Error, Result,
+ event::{DeviceId, DocAdd, FolderAdd, Op, Said},
+ model::{DEEPEST, DocId, FolderId, Kept},
+ state::State,
+};
+
+pub const EXTENSION: &str = "tistyx";
+const KIND: &str = "tisty-docs";
+const VERSION: u32 = 1;
+const MANIFEST: &str = "tisty-docs.json";
+const CARRIED: [&str; 2] = ["docs", "attachments"];
+const AT_MOST: u64 = 8 * 1024 * 1024 * 1024;
+const AT_MOST_FILES: usize = 200_000;
+const MANIFEST_AT_MOST: u64 = 16 * 1024 * 1024;
+const PAPERS_AT_MOST: usize = 50_000;
+const TITLE_AT_MOST: usize = 500;
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Manifest {
+ pub kind: String,
+ pub version: u32,
+ pub from: String,
+ pub folders: Vec,
+ pub docs: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Shelf {
+ pub id: String,
+ pub name: String,
+ pub order: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub parent: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub icon: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub color: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Paper {
+ pub file: String,
+ pub order: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub title: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub folder: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub page_of: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub wrote: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub made: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub by: Option,
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub archived: bool,
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub locked: bool,
+}
+
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+pub struct Step {
+ pub done: usize,
+ pub whole: usize,
+ pub bytes: u64,
+}
+
+#[derive(Default)]
+pub struct Along<'a> {
+ pub also: Option<&'a Path>,
+ pub say: Option<&'a dyn Fn(Step)>,
+}
+
+impl Along<'_> {
+ fn at(&self, done: usize, whole: usize, bytes: u64) {
+ if let Some(say) = self.say {
+ say(Step { done, whole, bytes });
+ }
+ }
+}
+
+#[derive(Debug, Default, Clone, PartialEq, Eq)]
+pub struct Sent {
+ pub docs: usize,
+ pub pages: usize,
+ pub folders: usize,
+ pub files: usize,
+ pub bytes: u64,
+ pub missed: usize,
+ pub left: Vec,
+}
+
+#[derive(Debug, Default, Clone, PartialEq, Eq)]
+pub struct Landed {
+ pub docs: usize,
+ pub pages: usize,
+ pub folders: usize,
+ pub joined: usize,
+ pub files: usize,
+ pub missed: usize,
+}
+
+pub fn write(
+ data: &Path,
+ state: &State,
+ which: &[String],
+ into: &Path,
+ along: &Along,
+) -> Result {
+ if into.starts_with(data) || data.starts_with(into) {
+ return Err(Error::OutsideTheStore(into.display().to_string()));
+ }
+ let papers = chosen(state, which);
+ if papers.is_empty() {
+ return Err(Error::NothingToCarry);
+ }
+
+ let made = filled(data, state, &papers, into, along);
+ if made.is_err() {
+ let _ = std::fs::remove_file(into);
+ }
+ made
+}
+
+fn filled(
+ data: &Path,
+ state: &State,
+ papers: &[&Kept],
+ into: &Path,
+ along: &Along,
+) -> Result {
+ let root = data.join("docs");
+ let mut sent = Sent::default();
+ let mut bodies: Vec<(&Kept, String)> = Vec::new();
+ for one in papers {
+ let Ok(body) = crate::docs::read(&root, &one.file) else {
+ sent.missed += 1;
+ continue;
+ };
+ bodies.push((one, body));
+ }
+
+ let mut beside: BTreeMap = BTreeMap::new();
+ for (_, body) in &bodies {
+ for one in crate::refs::extract(body).into_iter().map(|one| one.target) {
+ if !crate::attach::names_an_attachment(&one) || beside.contains_key(&one) {
+ continue;
+ }
+ match crate::attach::found(&one, data, along.also) {
+ Ok(from) if from.is_file() => {
+ let held = along.also.unwrap_or(data).join("attachments");
+ let named = from
+ .strip_prefix(data.join("attachments"))
+ .or_else(|_| from.strip_prefix(&held))
+ .map(|rest| {
+ format!("attachments/{}", rest.display()).replace(char::from(92), "/")
+ })
+ .unwrap_or_else(|_| one.clone());
+ beside.insert(named, from);
+ }
+ _ => {
+ if !sent.left.contains(&one) {
+ sent.left.push(one);
+ }
+ }
+ }
+ }
+ }
+
+ let held: BTreeSet<&str> = bodies.iter().map(|(one, _)| one.file.as_str()).collect();
+ let manifest = Manifest {
+ kind: KIND.into(),
+ version: VERSION,
+ from: crate::store::peek_identity(data.join("store")).unwrap_or_default(),
+ folders: shelves(state, &bodies),
+ docs: bodies
+ .iter()
+ .map(|(one, body)| Paper {
+ file: one.file.clone(),
+ order: one.order.clone(),
+ title: Some(
+ crate::docs::titled(body)
+ .chars()
+ .take(TITLE_AT_MOST)
+ .collect(),
+ ),
+ folder: one.folder.map(|at| at.to_string()),
+ page_of: one
+ .page_of
+ .and_then(|up| state.docs.get(&up))
+ .map(|up| up.file.clone())
+ .filter(|up| held.contains(up.as_str())),
+ wrote: one.wrote,
+ made: one.made,
+ by: one.by.clone().or_else(|| state.signed.alias.clone()),
+ archived: one.archived,
+ locked: one.locked,
+ })
+ .collect(),
+ };
+
+ let weighs = serde_json::to_string(&manifest)?.len() as u64;
+ if weighs > MANIFEST_AT_MOST || manifest.docs.len() > PAPERS_AT_MOST {
+ return Err(Error::TooBig);
+ }
+
+ let file = std::fs::File::create(into)?;
+ let _ = crate::paths::ours_alone(into);
+ let mut zip = zip::ZipWriter::new(file);
+ let plain = zip::write::SimpleFileOptions::default();
+ let kept = plain.compression_method(zip::CompressionMethod::Stored);
+
+ zip.start_file(MANIFEST, plain).map_err(zipped)?;
+ std::io::Write::write_all(
+ &mut zip,
+ serde_json::to_string_pretty(&manifest)?.as_bytes(),
+ )?;
+
+ let whole = bodies.len() + beside.len();
+ for (one, body) in &bodies {
+ zip.start_file(format!("docs/{}.md", one.file), plain)
+ .map_err(zipped)?;
+ std::io::Write::write_all(&mut zip, body.as_bytes())?;
+ sent.bytes = sent.bytes.saturating_add(body.len() as u64);
+ match one.page_of.is_some() {
+ true => sent.pages += 1,
+ false => sent.docs += 1,
+ }
+ along.at(sent.docs + sent.pages, whole, sent.bytes);
+ }
+
+ for (named, from) in &beside {
+ let Ok(weighs) = std::fs::metadata(from).map(|one| one.len()) else {
+ left_behind(&mut sent.left, named.clone());
+ continue;
+ };
+ sent.files += 1;
+ sent.bytes = sent.bytes.saturating_add(weighs);
+ if sent.bytes > AT_MOST || sent.files + bodies.len() > AT_MOST_FILES {
+ return Err(Error::TooBig);
+ }
+ let Ok(mut file) = std::fs::File::open(from) else {
+ sent.files -= 1;
+ sent.bytes = sent.bytes.saturating_sub(weighs);
+ left_behind(&mut sent.left, named.clone());
+ continue;
+ };
+ zip.start_file(named, kept).map_err(zipped)?;
+ std::io::copy(&mut file, &mut zip)?;
+ along.at(bodies.len() + sent.files, whole, sent.bytes);
+ }
+
+ sent.folders = manifest.folders.len();
+ zip.finish().map_err(zipped)?;
+ match sent.docs + sent.pages {
+ 0 => Err(Error::NothingToCarry),
+ _ => Ok(sent),
+ }
+}
+
+pub fn plainly(
+ data: &Path,
+ state: &State,
+ which: &[String],
+ into: &Path,
+ along: &Along,
+) -> Result {
+ if into.starts_with(data) || data.starts_with(into) {
+ return Err(Error::OutsideTheStore(into.display().to_string()));
+ }
+ let papers = chosen(state, which);
+ if papers.is_empty() {
+ return Err(Error::NothingToCarry);
+ }
+
+ let mut sent = Sent::default();
+ let mut shelves: BTreeSet = BTreeSet::new();
+ let fresh = !into.exists();
+ let shelved = trails(state, into);
+ let whole = papers.iter().filter(|one| one.page_of.is_none()).count();
+ for one in papers.iter().filter(|one| one.page_of.is_none()) {
+ let under = one
+ .folder
+ .and_then(|at| shelved.get(&at).cloned())
+ .unwrap_or_else(|| into.to_path_buf());
+ if let Err(e) = std::fs::create_dir_all(&under) {
+ if fresh {
+ let _ = std::fs::remove_dir_all(into);
+ }
+ return Err(Error::Io(e));
+ }
+ for at in under.ancestors().take_while(|at| *at != into) {
+ shelves.insert(at.to_path_buf());
+ }
+
+ let pages: Vec = state
+ .pages_of(one.id)
+ .iter()
+ .map(|page| page.file.clone())
+ .collect();
+ let named = free(
+ &under,
+ &crate::docs::spelled(match one.title.as_deref() {
+ Some(said) if !said.is_empty() => said,
+ _ => one.file.as_str(),
+ }),
+ );
+ let Ok(took) =
+ crate::docs::laid_out_as(data, &one.file, &pages, &under, Some(&named), along.also)
+ else {
+ sent.missed += 1;
+ continue;
+ };
+ sent.docs += 1;
+ sent.missed += took.missed;
+ sent.pages += pages.len() - took.missed;
+ sent.files += took.files;
+ along.at(sent.docs, whole, sent.bytes);
+ for gone in took.left {
+ left_behind(&mut sent.left, gone);
+ }
+ }
+ sent.folders = shelves.len();
+ Ok(sent)
+}
+
+fn trails(state: &State, into: &Path) -> BTreeMap {
+ let mut found: BTreeMap = BTreeMap::new();
+ let mut left: Vec<(Option, PathBuf)> = vec![(None, into.to_path_buf())];
+ while let Some((parent, at)) = left.pop() {
+ let mut taken: BTreeSet = BTreeSet::new();
+ for one in state.under(parent) {
+ if found.contains_key(&one.id) {
+ continue;
+ }
+ let mut named = crate::docs::spelled(&one.name);
+ if !taken.insert(named.clone()) {
+ for n in 2..100 {
+ let tried = format!("{named} {n}");
+ if taken.insert(tried.clone()) {
+ named = tried;
+ break;
+ }
+ }
+ }
+ let mine = at.join(&named);
+ found.insert(one.id, mine.clone());
+ left.push((Some(one.id), mine));
+ }
+ }
+ found
+}
+
+fn free(under: &Path, named: &str) -> String {
+ if !under.join(named).exists() {
+ return named.to_string();
+ }
+ for n in 2..100 {
+ let tried = format!("{named} {n}");
+ if !under.join(&tried).exists() {
+ return tried;
+ }
+ }
+ named.to_string()
+}
+
+fn left_behind(left: &mut Vec, one: String) {
+ if !left.contains(&one) {
+ left.push(one);
+ }
+}
+
+fn chosen<'a>(state: &'a State, which: &[String]) -> Vec<&'a Kept> {
+ let asked: BTreeSet<&str> = which.iter().map(String::as_str).collect();
+ let mut found: Vec<&Kept> = state
+ .docs
+ .values()
+ .filter(|one| {
+ asked.is_empty()
+ || asked.contains(one.file.as_str())
+ || one.page_of.is_some_and(|up| {
+ state
+ .docs
+ .get(&up)
+ .is_some_and(|up| asked.contains(up.file.as_str()))
+ })
+ })
+ .collect();
+ found.sort_by(|a, b| {
+ a.page_of
+ .is_some()
+ .cmp(&b.page_of.is_some())
+ .then(a.order.cmp(&b.order))
+ .then(a.id.cmp(&b.id))
+ });
+ found
+}
+
+fn shelves(state: &State, bodies: &[(&Kept, String)]) -> Vec {
+ let mut wanted: BTreeSet = BTreeSet::new();
+ for (one, _) in bodies {
+ let mut at = one.folder;
+ while let Some(id) = at {
+ if !wanted.insert(id) {
+ break;
+ }
+ at = state.folders.get(&id).and_then(|one| one.parent);
+ }
+ }
+
+ let mut found: Vec = wanted
+ .iter()
+ .filter_map(|id| state.folders.get(id))
+ .map(|one| Shelf {
+ id: one.id.to_string(),
+ name: one.name.clone(),
+ order: one.order.clone(),
+ parent: one
+ .parent
+ .filter(|up| wanted.contains(up))
+ .map(|up| up.to_string()),
+ icon: one.icon.clone(),
+ color: one.color.clone(),
+ })
+ .collect();
+ found.sort_by(|a, b| a.order.cmp(&b.order).then(a.id.cmp(&b.id)));
+ found
+}
+
+pub fn read(
+ data: &Path,
+ state: &State,
+ device: &DeviceId,
+ from: &Path,
+ along: &Along,
+) -> Result<(Landed, Vec)> {
+ let file = std::fs::File::open(from)?;
+ let mut zip = zip::ZipArchive::new(file).map_err(zipped)?;
+ let manifest = manifest_in(&mut zip, from)?;
+
+ let mine = crate::store::peek_identity(data.join("store"));
+ let elsewhere = match (&mine, manifest.from.trim()) {
+ (Some(mine), from) if !from.is_empty() => mine != from,
+ _ => true,
+ };
+ let staged = data.join(format!(".landing-{}", std::process::id()));
+ swept(data);
+ let whole = zip.len() + manifest.docs.len();
+ let done = unpack(&mut zip, &staged, along, whole).and_then(|_| {
+ taken_in(
+ data,
+ state,
+ device,
+ &Landing {
+ manifest: &manifest,
+ staged: &staged,
+ along,
+ whole,
+ elsewhere,
+ },
+ )
+ });
+ let _ = std::fs::remove_dir_all(&staged);
+ done
+}
+
+pub fn swept(data: &Path) {
+ let Ok(entries) = std::fs::read_dir(data) else {
+ return;
+ };
+ let mine = format!(".landing-{}", std::process::id());
+ for at in entries.filter_map(|one| one.ok()).map(|one| one.path()) {
+ let stale = at.is_dir()
+ && at
+ .file_name()
+ .and_then(|one| one.to_str())
+ .is_some_and(|one| one.starts_with(".landing-") && one != mine);
+ if stale && std::fs::remove_dir_all(&at).is_err() {
+ crate::witness::warn(
+ crate::witness::channel::BACKUP,
+ "what an interrupted landing left behind could not be swept",
+ &[("at", crate::witness::Fact::Path(at.clone()))],
+ );
+ }
+ }
+}
+
+fn manifest_in(zip: &mut zip::ZipArchive, from: &Path) -> Result