From 225814138f96e66b41d669b11fe6496397a7548f Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 6 Sep 2026 15:37:09 -0300 Subject: [PATCH 1/8] fix: what could not come along is named, and a number is still not a tag --- app/src-tauri/src/lib.rs | 22 +++++++-- app/src/App.tsx | 6 +++ app/src/core.ts | 1 + app/src/locales.ts | 4 ++ app/src/tests/lifecycle.test.tsx | 21 +++++++- app/src/ui/Docs.tsx | 12 +++++ crates/tisty-cli/src/cmd/task.rs | 2 +- crates/tisty-cli/src/mcp.rs | 12 ++++- crates/tisty-core/src/capture.rs | 2 +- crates/tisty-core/src/docs.rs | 78 +++++++++++++++++++++++++++--- crates/tisty-core/src/model/tag.rs | 18 ++++++- crates/tisty-core/src/state.rs | 56 ++++++++++++++++++--- crates/tisty-core/src/tagging.rs | 7 +++ 13 files changed, 216 insertions(+), 25 deletions(-) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 87b7c920..e62ac9a6 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1412,7 +1412,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); } @@ -3660,9 +3660,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 +3684,7 @@ fn doc_export( struct Taken { files: usize, missed: usize, + left: usize, } #[tauri::command(async)] diff --git a/app/src/App.tsx b/app/src/App.tsx index 5578b846..f4bb8fd0 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -760,6 +760,12 @@ 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); }) diff --git a/app/src/core.ts b/app/src/core.ts index 13b93f23..d76aa8ad 100644 --- a/app/src/core.ts +++ b/app/src/core.ts @@ -726,6 +726,7 @@ 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 => diff --git a/app/src/locales.ts b/app/src/locales.ts index ec1606fd..6e1ee964 100644 --- a/app/src/locales.ts +++ b/app/src/locales.ts @@ -477,6 +477,8 @@ const en = { 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", + 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", @@ -1613,6 +1615,8 @@ const es: Catalog = { 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", + 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", diff --git a/app/src/tests/lifecycle.test.tsx b/app/src/tests/lifecycle.test.tsx index 53ba9eb0..9c14a7c8 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,8 @@ 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 carrier = vi.hoisted(() => ({ made: 0, asked: 0 })); const ipc = vi.hoisted(() => ({ @@ -180,7 +182,7 @@ function backend(cmd: string, args: Record): Promise { return Promise.resolve(null); } case "doc_export": - return Promise.resolve(0); + return Promise.resolve({ ...taking }); case "folder_rename": { const folder = store.folders.find((one) => one.id === args.id); if (folder) folder.name = String(args.name); @@ -222,6 +224,9 @@ beforeEach(() => { store.copied = []; store.seq = 0; picked.path = Promise.resolve(null); + taking.files = 0; + taking.missed = 0; + taking.left = 0; carrier.made = 0; carrier.asked = 0; ipc.answer = backend; @@ -613,6 +618,18 @@ describe("what the menus reach for outside the tree", () => { await waitFor(() => expect(screen.getByText(t("takenOutAlone"))).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/Docs.tsx b/app/src/ui/Docs.tsx index caba5b2d..22f53a20 100644 --- a/app/src/ui/Docs.tsx +++ b/app/src/ui/Docs.tsx @@ -711,6 +711,18 @@ export default function Docs({ onTakeOut={() => { 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/crates/tisty-cli/src/cmd/task.rs b/crates/tisty-cli/src/cmd/task.rs index f9bf4dab..d98a5ac5 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 1c17ad42..7d11eab1 100644 --- a/crates/tisty-cli/src/mcp.rs +++ b/crates/tisty-cli/src/mcp.rs @@ -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); @@ -2107,13 +2107,20 @@ fn export_doc(paths: &Paths, args: &Value) -> Result { 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 +2129,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/capture.rs b/crates/tisty-core/src/capture.rs index a2f5ef51..6021ce3d 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 f4b297e4..72f4f997 100644 --- a/crates/tisty-core/src/docs.rs +++ b/crates/tisty-core/src/docs.rs @@ -642,10 +642,11 @@ 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 { @@ -700,11 +701,16 @@ pub fn with_pages(data: &Path, id: &str, pages: &[String], into: &Path) -> Resul &format!("{named}.{EXTENSION}"), )?; for (_, at, body) in &written { - taken += laid_out(data, &beside(body), &folder, at)?; + let more = laid_out(data, &beside(body), &folder, at)?; + 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,27 +749,40 @@ 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 laid_out(data: &Path, body: &str, folder: &Path, named: &str) -> 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 { + left_behind(&mut taken.left, one); continue; }; let Ok(rest) = from.strip_prefix(&held) 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) @@ -3863,6 +3882,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()).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/model/tag.rs b/crates/tisty-core/src/model/tag.rs index e7fe0255..1fb5fa0a 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/state.rs b/crates/tisty-core/src/state.rs index 755e232f..e0cdb14c 100644 --- a/crates/tisty-core/src/state.rs +++ b/crates/tisty-core/src/state.rs @@ -276,11 +276,12 @@ impl State { page_of, archived: under.is_some_and(|one| one.archived), locked: false, - tags: d - .said - .as_ref() - .and_then(|one| one.tags.clone()) - .unwrap_or_default(), + tags: crate::tagging::worth_keeping( + &d.said + .as_ref() + .and_then(|one| one.tags.clone()) + .unwrap_or_default(), + ), }, ); } @@ -290,7 +291,7 @@ impl State { kept.bytes = d.bytes; // A note from a build that never read tags says nothing about them. if let Some(tags) = &d.tags { - kept.tags = tags.clone(); + kept.tags = crate::tagging::worth_keeping(tags); } kept.wrote = Some(event.timestamp); } @@ -1166,7 +1167,7 @@ fn task_from(id: TaskId, d: &TaskAdd) -> Task { date: d.date.clone(), deadline: d.deadline.clone(), list: d.list, - tags: d.tags.clone(), + tags: crate::tagging::worth_keeping(&d.tags), reminders: d.reminders.clone(), repeat: d.repeat, after: d.after, @@ -1189,7 +1190,7 @@ fn patch(task: &mut Task, d: &TaskPatch) { task.priority = v; } if let Some(v) = &d.tags { - task.tags = v.clone(); + task.tags = crate::tagging::worth_keeping(v); } if let Some(v) = &d.reminders { task.reminders = v.clone(); @@ -3134,6 +3135,45 @@ mod tests { assert_eq!(state.tasks_tagged(&Tag::new("work").unwrap()).count(), 2); } + #[test] + fn a_number_filed_as_a_tag_under_an_older_rule_is_not_read_back_as_one() { + let (task, doc) = (Ulid::generate(), Ulid::generate()); + let state = State::replay(&[ + ev( + 1, + "dev_a", + Op::TaskAdd { + id: task, + d: TaskAdd { + tags: vec![Tag::new("1").unwrap(), Tag::new("legal").unwrap()], + ..TaskAdd::new("one", "a0") + }, + }, + ), + ev( + 2, + "dev_a", + Op::DocAdd { + id: doc, + d: crate::event::DocAdd { + file: "dev0-0001".into(), + order: "a0".into(), + said: Some(crate::event::Said { + title: "Acta".into(), + bytes: None, + tags: Some(vec![Tag::new("2").unwrap(), Tag::new("casa").unwrap()]), + }), + ..Default::default() + }, + }, + ), + ]); + + assert_eq!(state.tasks[&task].tags, [Tag::new("legal").unwrap()]); + assert_eq!(state.docs[&doc].tags, [Tag::new("casa").unwrap()]); + assert_eq!(state.tags().len(), 2); + } + #[test] fn archived_tasks_stay_out_of_the_open_views_but_remain() { let id = Ulid::generate(); diff --git a/crates/tisty-core/src/tagging.rs b/crates/tisty-core/src/tagging.rs index 5a8e0ac4..edc32de9 100644 --- a/crates/tisty-core/src/tagging.rs +++ b/crates/tisty-core/src/tagging.rs @@ -8,6 +8,13 @@ use crate::model::Tag; /// them is written once and kept forever, on every machine. pub const AT_MOST: usize = 64; +pub fn worth_keeping(tags: &[Tag]) -> Vec { + tags.iter() + .filter(|one| one.worth_reading()) + .cloned() + .collect() +} + pub fn tags_in(body: &str) -> Vec { let mut found: Vec = Vec::new(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); From 4c50e23b9a394df68202a758d51b72eceafc37c7 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 6 Sep 2026 18:02:02 -0300 Subject: [PATCH 2/8] feat: a document travels to another Tisty and lands as its own --- app/src-tauri/src/lib.rs | 246 ++++++++ app/src/App.tsx | 141 ++++- app/src/core.ts | 31 + app/src/locales.ts | 36 ++ app/src/refusal.ts | 2 + app/src/tests/lifecycle.test.tsx | 136 +++++ crates/tisty-cli/src/mcp.rs | 8 +- crates/tisty-core/src/attach.rs | 11 + crates/tisty-core/src/docs.rs | 56 +- crates/tisty-core/src/lib.rs | 1 + crates/tisty-core/src/parcel.rs | 774 +++++++++++++++++++++++++ crates/tisty-core/tests/pages_files.rs | 28 +- crates/tisty-core/tests/parcelled.rs | 645 +++++++++++++++++++++ 13 files changed, 2092 insertions(+), 23 deletions(-) create mode 100644 crates/tisty-core/src/parcel.rs create mode 100644 crates/tisty-core/tests/parcelled.rs diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index e62ac9a6..721cd5cd 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -2036,6 +2036,8 @@ const REFUSALS: &[&str] = &[ "stillCarrying", "sandboxCannotMerge", "noSuchDoc", + "notAParcel", + "tooBig", "noSuchIcon", "noSuchColour", "noSuchFolder", @@ -3643,11 +3645,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( @@ -3687,6 +3691,245 @@ struct Taken { 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(async)] +async fn docs_pack( + app: tauri::AppHandle, + session: tauri::State<'_, Mutex>, + alone: tauri::State<'_, OneAtATime>, + 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"), + _ => 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<'_, OneAtATime>, + 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()))], + ); + 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<'_, OneAtATime>, + 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::NotForAnAgent(_) => Refusal::about("notAParcel", from.clone()), + tisty_core::Error::UnsupportedVersion(_) => Refusal::of("storeNewer"), + 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)] fn doc_import( session: tauri::State<'_, Mutex>, @@ -5754,6 +5997,9 @@ pub fn run() { doc_drop, doc_import, doc_export, + docs_pack, + docs_take_out, + docs_unpack, doc_copy, doc_adopt, doc_let_go, diff --git a/app/src/App.tsx b/app/src/App.tsx index f4bb8fd0..5649f30b 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, @@ -59,7 +63,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 +123,8 @@ export const kept = (key: string): string[] => { const LOOKS_AGAIN = 6 * 60 * 60 * 1000; +const PARCEL = "tistydoc"; + export default function App() { const [data, setData] = useState(null); const [error, setError] = useState(null); @@ -185,6 +191,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 +228,94 @@ export default function App() { }) .catch((e) => setError(saidPlainly(e))); + const packUp = (which: string[], named: string) => + intoFile({ + defaultPath: `${named}.${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; + if (packed.left > 0) { + setError(packed.left === 1 ? t("takenLess") : fill("takenLesser", String(packed.left))); + return; + } + setNote(packed.docs ? fill("packed", String(packed.docs)) : 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; + if (took.missed > 0) { + setError(took.missed === 1 ? t("takenShort") : fill("takenShorter", String(took.missed))); + return; + } + if (took.left > 0) { + setError(took.left === 1 ? t("takenLess") : fill("takenLesser", String(took.left))); + return; + } + const many = String(took.docs); + setNote( + took.folders + ? fill("tookOutAll", many, String(took.folders)) + : fill("tookOutAllFlat", 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(); + if (landed.docs + landed.pages === 0) { + setError(t("landedNone")); + return; + } + if (landed.missed > 0) { + setError(fill("landedShort", String(landed.missed))); + return; + } + const many = String(landed.docs + landed.pages); + setNote( + landed.folders + ? fill("landedIn", many, String(landed.folders)) + : fill("landedAlone", 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 +586,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 +711,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() }, ], }); @@ -771,6 +878,12 @@ export default function App() { }) .catch((e) => setError(saidPlainly(e))), }, + { + key: "packIt", + icon: "⇪", + label: t("packIt"), + onPick: () => packUp([doc.file], doc.title || doc.file), + }, { key: "seePdf", icon: "▤", @@ -928,7 +1041,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))} /> )} @@ -1077,6 +1209,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() }, ], }) } diff --git a/app/src/core.ts b/app/src/core.ts index d76aa8ad..512af004 100644 --- a/app/src/core.ts +++ b/app/src/core.ts @@ -731,6 +731,37 @@ export interface Taken { 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 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 6e1ee964..d4d0bd70 100644 --- a/app/src/locales.ts +++ b/app/src/locales.ts @@ -517,6 +517,24 @@ 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", + unpackIt: "Import from Tisty…", + packed: "Packed with {name} documents", + packedAlone: "Packed", + landedIn: "{name} documents came in, {other} folders among them", + landedAlone: "{name} documents came in", + landedNone: "Nothing came in: the parcel held no documents", + landedShort: "It came in, but {name} of the files it points at did not fit and stayed out", + notAParcel: "{name} is not a Tisty parcel", + tooBig: "That is larger than Tisty will carry", imported: "Imported as a new document", renameFolder: "Rename {name}", dropFolder: "Delete {name}", @@ -1655,6 +1673,24 @@ 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", + unpackIt: "Importar desde Tisty…", + packed: "Empaquetado con {name} documentos", + packedAlone: "Empaquetado", + landedIn: "Entraron {name} documentos, con {other} carpetas", + landedAlone: "Entraron {name} documentos", + landedNone: "No entró nada: el paquete no traía documentos", + landedShort: "Entró, pero {name} de los archivos a los que apunta no cupieron y quedaron fuera", + notAParcel: "{name} no es un paquete de Tisty", + tooBig: "Eso pasa de lo que Tisty puede llevar", imported: "Importado como documento nuevo", renameFolder: "Renombrar {name}", dropFolder: "Borrar {name}", diff --git a/app/src/refusal.ts b/app/src/refusal.ts index 6645d3fb..f969f9f8 100644 --- a/app/src/refusal.ts +++ b/app/src/refusal.ts @@ -65,6 +65,8 @@ const KNOWN = [ "internalNamed", "noSuchFolder", "noSuchDoc", + "notAParcel", + "tooBig", "deleteRefused", "alreadyKept", "shedAlready", diff --git a/app/src/tests/lifecycle.test.tsx b/app/src/tests/lifecycle.test.tsx index 9c14a7c8..c9737e84 100644 --- a/app/src/tests/lifecycle.test.tsx +++ b/app/src/tests/lifecycle.test.tsx @@ -36,6 +36,17 @@ 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(() => ({ @@ -76,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", () => ({ @@ -183,6 +195,35 @@ function backend(cmd: string, args: Record): Promise { } case "doc_export": 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); @@ -227,6 +268,14 @@ beforeEach(() => { 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; @@ -618,6 +667,93 @@ 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.tistydoc"); + 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.tistydoc"); + 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.tistydoc"); + 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("says what came in when a parcel is taken in, folders and all", async () => { + picked.path = Promise.resolve("D:/entrada/tisty.tistydoc"); + 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.tistydoc"); + 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"); diff --git a/crates/tisty-cli/src/mcp.rs b/crates/tisty-cli/src/mcp.rs index 7d11eab1..5f5f7f01 100644 --- a/crates/tisty-cli/src/mcp.rs +++ b/crates/tisty-cli/src/mcp.rs @@ -2103,7 +2103,13 @@ 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!( diff --git a/crates/tisty-core/src/attach.rs b/crates/tisty-core/src/attach.rs index df9570f3..1eb0f0b4 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/docs.rs b/crates/tisty-core/src/docs.rs index 72f4f997..fd559612 100644 --- a/crates/tisty-core/src/docs.rs +++ b/crates/tisty-core/src/docs.rs @@ -650,18 +650,40 @@ pub struct Taken { } 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)?; @@ -699,9 +721,10 @@ 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 { - let more = 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); @@ -755,7 +778,19 @@ fn left_behind(left: &mut Vec, one: String) { } } -fn laid_out(data: &Path, body: &str, folder: &Path, named: &str) -> Result { +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"); @@ -764,11 +799,11 @@ fn laid_out(data: &Path, body: &str, folder: &Path, named: &str) -> Result Result String { +pub(crate) fn spelled(said: &str) -> String { let flat: String = said .chars() .map(|c| { @@ -3757,6 +3792,7 @@ despues "mac0-0001", &["mac0-0002".into(), "mac0-0003".into()], out.path(), + None, ) .unwrap(); @@ -3924,7 +3960,7 @@ despues .unwrap(); let out = tempfile::tempdir().unwrap(); - let taken = with_pages(data, "mac0-0001", &["mac0-0002".into()], out.path()).unwrap(); + let taken = with_pages(data, "mac0-0001", &["mac0-0002".into()], out.path(), None).unwrap(); assert_eq!(taken.left, ["attachments/6d/clip-da1d77da.mov"]); } diff --git a/crates/tisty-core/src/lib.rs b/crates/tisty-core/src/lib.rs index 143b17b4..b799446e 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; diff --git a/crates/tisty-core/src/parcel.rs b/crates/tisty-core/src/parcel.rs new file mode 100644 index 00000000..fd8158c4 --- /dev/null +++ b/crates/tisty-core/src/parcel.rs @@ -0,0 +1,774 @@ +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 = "tistydoc"; +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; + +#[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 = "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::OutsideTheStore(into.display().to_string())); + } + + 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 { + 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() => { + beside.insert(one, 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)), + 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, + archived: one.archived, + locked: one.locked, + }) + .collect(), + }; + + 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 weighs = std::fs::metadata(from)?.len(); + 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); + } + zip.start_file(named, kept).map_err(zipped)?; + let mut file = std::fs::File::open(from)?; + 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)?; + 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::OutsideTheStore(into.display().to_string())); + } + + let mut sent = Sent::default(); + let mut shelves: BTreeSet = BTreeSet::new(); + 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 = trail(state, one.folder, into); + std::fs::create_dir_all(&under)?; + 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 trail(state: &State, folder: Option, into: &Path) -> PathBuf { + let mut names: Vec = Vec::new(); + let mut at = folder; + while let Some(id) = at { + let Some(one) = state.folders.get(&id) else { + break; + }; + names.push(crate::docs::spelled(&one.name)); + at = one.parent; + if names.len() > DEEPEST { + break; + } + } + names + .iter() + .rev() + .fold(into.to_path_buf(), |at, one| at.join(one)) +} + +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 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, &manifest, &staged, along, whole)); + let _ = std::fs::remove_dir_all(&staged); + done +} + +fn swept(data: &Path) { + let Ok(entries) = std::fs::read_dir(data) else { + return; + }; + 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-")); + 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 { + let mut held = zip + .by_name(MANIFEST) + .map_err(|_| Error::NotForAnAgent(from.display().to_string()))?; + let mut said = String::new(); + held.read_to_string(&mut said)?; + let manifest: Manifest = serde_json::from_str(&said) + .map_err(|_| Error::NotForAnAgent(from.display().to_string()))?; + if manifest.kind != KIND { + return Err(Error::NotForAnAgent(from.display().to_string())); + } + if manifest.version > VERSION { + return Err(Error::UnsupportedVersion(manifest.version)); + } + Ok(manifest) +} + +fn taken_in( + data: &Path, + state: &State, + device: &DeviceId, + manifest: &Manifest, + staged: &Path, + along: &Along, + whole: usize, +) -> Result<(Landed, Vec)> { + let mut landed = Landed::default(); + let mut ops: Vec = Vec::new(); + + let filed = shelved(state, manifest, &mut landed, &mut ops); + + let root = data.join("docs"); + let mut carried: BTreeMap = BTreeMap::new(); + let mut named: BTreeMap = BTreeMap::new(); + let mut ordered: Vec<(Option, String)> = Vec::new(); + let mut written: Vec<(String, String)> = Vec::new(); + + for paper in ordering(manifest) { + let Ok(body) = + std::fs::read_to_string(staged.join("docs").join(format!("{}.md", paper.file))) + else { + landed.missed += 1; + continue; + }; + let body = brought(data, staged, &body, &mut carried, &mut landed); + + let up = paper + .page_of + .as_ref() + .and_then(|file| named.get(file)) + .map(|(_, id)| *id); + if paper.page_of.is_some() && up.is_none() { + landed.missed += 1; + continue; + } + let folder = match up { + Some(_) => None, + None => paper.folder.as_ref().and_then(|at| filed.get(at)).copied(), + }; + + let made = crate::docs::create(&root, device, &body)?; + let id = Ulid::generate(); + named.insert(paper.file.clone(), (made.id.clone(), id)); + + let order = crate::order::last_of( + state + .docs + .values() + .filter(|one| one.folder == folder && one.page_of.is_none()) + .map(|one| one.order.as_str()) + .chain( + ordered + .iter() + .filter(|(at, _)| *at == folder) + .map(|(_, key)| key.as_str()), + ), + ); + ordered.push((folder, order.clone())); + + ops.push(Op::DocAdd { + id, + d: DocAdd { + file: made.id.clone(), + order, + said: Some(Said { + title: made.title.clone(), + bytes: Some(body.len() as u64), + tags: Some(crate::tagging::tags_in(&body)), + }), + folder, + page_of: up, + }, + }); + if paper.archived { + ops.push(Op::DocArchive { id }); + } + if paper.locked { + ops.push(Op::DocLock { id }); + } + match up.is_some() { + true => landed.pages += 1, + false => landed.docs += 1, + } + along.at( + whole - manifest.docs.len() + landed.docs + landed.pages, + whole, + 0, + ); + written.push((made.id, body)); + } + + for (file, body) in written { + let told = pointed(&body, &named); + if told != body { + crate::docs::write(&root, &file, &told)?; + } + } + + Ok((landed, ops)) +} + +fn ordering(manifest: &Manifest) -> Vec<&Paper> { + let mut found: Vec<&Paper> = manifest + .docs + .iter() + .filter(|one| one.page_of.is_none()) + .collect(); + for one in &manifest.docs { + if one.page_of.is_some() { + found.push(one); + } + } + found +} + +fn shelved( + state: &State, + manifest: &Manifest, + landed: &mut Landed, + ops: &mut Vec, +) -> BTreeMap { + let mut filed: BTreeMap = BTreeMap::new(); + let mut deep: BTreeMap = BTreeMap::new(); + let mut fresh: Vec<(FolderId, Option, String, String)> = Vec::new(); + + for shelf in downwards(manifest) { + let parent = shelf.parent.as_ref().and_then(|up| filed.get(up)).copied(); + let under = match parent { + Some(at) => deep + .get(&at) + .copied() + .unwrap_or_else(|| state.depth(Some(at))), + None => 0, + }; + if under >= DEEPEST { + if let Some(at) = parent { + filed.insert(shelf.id.clone(), at); + } + continue; + } + + let standing = state + .folders + .values() + .find(|one| one.parent == parent && alike(&one.name, &shelf.name)) + .map(|one| one.id) + .or_else(|| { + fresh + .iter() + .find(|(_, up, name, _)| *up == parent && alike(name, &shelf.name)) + .map(|(id, ..)| *id) + }); + if let Some(id) = standing { + landed.joined += 1; + deep.insert(id, under + 1); + filed.insert(shelf.id.clone(), id); + continue; + } + + let mut keys: Vec = state + .under(parent) + .iter() + .map(|one| one.order.clone()) + .collect(); + keys.extend( + fresh + .iter() + .filter(|(_, up, _, _)| *up == parent) + .map(|(_, _, _, key)| key.clone()), + ); + let order = crate::order::last_of(keys.iter().map(String::as_str)); + + let id = Ulid::generate(); + ops.push(Op::FolderAdd { + id, + d: FolderAdd { + name: shelf.name.clone(), + order: order.clone(), + parent, + icon: shelf + .icon + .clone() + .filter(|one| crate::model::icon::known(one)), + color: shelf + .color + .as_ref() + .and_then(|one| crate::model::hue::kept(one)) + .map(str::to_string), + }, + }); + fresh.push((id, parent, shelf.name.clone(), order)); + deep.insert(id, under + 1); + filed.insert(shelf.id.clone(), id); + landed.folders += 1; + } + filed +} + +fn downwards(manifest: &Manifest) -> Vec<&Shelf> { + let mut found: Vec<&Shelf> = Vec::new(); + let mut done: BTreeSet<&str> = BTreeSet::new(); + let mut left: Vec<&Shelf> = manifest.folders.iter().collect(); + while !left.is_empty() { + let (ready, waiting): (Vec<&Shelf>, Vec<&Shelf>) = left.into_iter().partition(|one| { + one.parent + .as_ref() + .is_none_or(|up| done.contains(up.as_str())) + }); + if ready.is_empty() { + found.extend(waiting); + break; + } + for one in &ready { + done.insert(one.id.as_str()); + } + found.extend(ready); + left = waiting; + } + found +} + +fn alike(one: &str, other: &str) -> bool { + crate::text::composed(one.trim()).to_lowercase() + == crate::text::composed(other.trim()).to_lowercase() +} + +fn brought( + data: &Path, + staged: &Path, + body: &str, + carried: &mut BTreeMap, + landed: &mut Landed, +) -> String { + let mut told = body.to_string(); + for one in crate::refs::extract(body).into_iter().map(|one| one.target) { + if !crate::attach::names_an_attachment(&one) { + continue; + } + if !carried.contains_key(&one) { + let Some(at) = safe(&one) else { + continue; + }; + let from = staged.join(at); + if !from.is_file() { + landed.missed += 1; + continue; + } + let Ok(kept) = crate::attach::keep(&from, data, crate::attach::COPIED_IN_DOC) else { + landed.missed += 1; + continue; + }; + landed.files += 1; + carried.insert(one.clone(), kept.at); + } + if let Some(now) = carried.get(&one) + && *now != one + { + told = told.replace(&one, now); + } + } + told +} + +fn pointed(body: &str, named: &BTreeMap) -> String { + let mut told = body.to_string(); + for file in crate::refs::papers(body) { + if let Some((now, _)) = named.get(&file) { + told = told.replace( + &format!("{}{file}", crate::refs::DOC), + &format!("{}{now}", crate::refs::DOC), + ); + } + } + told +} + +fn unpack( + zip: &mut zip::ZipArchive, + into: &Path, + along: &Along, + whole: usize, +) -> Result { + if zip.len() > AT_MOST_FILES { + return Err(Error::TooBig); + } + let mut files = 0; + let mut bytes = 0u64; + + for i in 0..zip.len() { + let mut held = zip.by_index(i).map_err(zipped)?; + if held.is_dir() { + continue; + } + let Some(rest) = safe(held.name()) else { + continue; + }; + if held.size() > AT_MOST.saturating_sub(bytes) { + return Err(Error::TooBig); + } + + let at = into.join(&rest); + if let Some(parent) = at.parent() { + std::fs::create_dir_all(parent)?; + } + let mut file = std::fs::File::create(&at)?; + let _ = crate::paths::ours_alone(&at); + let room = AT_MOST.saturating_sub(bytes).saturating_add(1); + let written = std::io::copy(&mut held.by_ref().take(room), &mut file)?; + if written >= room { + return Err(Error::TooBig); + } + bytes = bytes.saturating_add(written); + files += 1; + along.at(i + 1, whole, bytes); + } + Ok(files) +} + +fn safe(named: &str) -> Option { + let at = Path::new(named); + if !at + .components() + .all(|part| matches!(part, Component::Normal(_))) + { + return None; + } + let head = at.components().next()?.as_os_str().to_str()?; + CARRIED.contains(&head).then(|| at.to_path_buf()) +} + +fn zipped(e: zip::result::ZipError) -> Error { + Error::Io(std::io::Error::other(e.to_string())) +} diff --git a/crates/tisty-core/tests/pages_files.rs b/crates/tisty-core/tests/pages_files.rs index 5b0aac3f..5df2b15c 100644 --- a/crates/tisty-core/tests/pages_files.rs +++ b/crates/tisty-core/tests/pages_files.rs @@ -418,7 +418,7 @@ fn exporting_ten_pages_carries_the_cover_the_pages_in_order_and_every_attachment assert_eq!(page_files.len(), 10); let out = tmp(); - let taken = docs::with_pages(data, &book_file, &page_files, out.path()).unwrap(); + let taken = docs::with_pages(data, &book_file, &page_files, out.path(), None).unwrap(); let folder = out.path().join("Book"); assert!(folder.join("Book.md").exists()); @@ -473,7 +473,7 @@ fn two_pages_that_both_fall_back_to_the_generic_name_still_export_as_two_files() .map(|one| one.file.clone()) .collect(); let out = tmp(); - docs::with_pages(data, &book_file, &page_files, out.path()).unwrap(); + docs::with_pages(data, &book_file, &page_files, out.path(), None).unwrap(); let folder = out.path().join("Simbolos"); let a = std::fs::read_to_string(folder.join("01 documento.md")).unwrap(); @@ -523,8 +523,17 @@ fn exporting_with_pages_into_a_path_inside_the_store_is_refused_before_anything_ let (book, book_file) = add_doc(&mut state, data, &dev, &mut seq, "# Guardado\n\ntexto"); let (_, page_file) = add_page(&mut state, data, &dev, &mut seq, book, "# Pagina\n\ntexto"); - assert!(docs::with_pages(data, &book_file, std::slice::from_ref(&page_file), data).is_err()); - assert!(docs::with_pages(data, &book_file, &[page_file], &data.join("docs")).is_err()); + assert!( + docs::with_pages( + data, + &book_file, + std::slice::from_ref(&page_file), + data, + None + ) + .is_err() + ); + assert!(docs::with_pages(data, &book_file, &[page_file], &data.join("docs"), None).is_err()); } #[test] @@ -556,7 +565,8 @@ fn exporting_skips_a_page_whose_file_vanished_from_disk_without_aborting_the_res docs::remove(&data.join("docs"), &gone_file).unwrap(); let out = tmp(); - let taken = docs::with_pages(data, &book_file, &[gone_file, kept_file], out.path()).unwrap(); + let taken = + docs::with_pages(data, &book_file, &[gone_file, kept_file], out.path(), None).unwrap(); assert_eq!(taken.files, 0); assert_eq!( @@ -797,7 +807,7 @@ fn the_way_into_a_page_is_the_file_beside_it_once_the_book_is_out_of_tisty() { .map(|one| one.file.clone()) .collect(); let out = tmp(); - docs::with_pages(data, &book_file, &page_files, out.path()).unwrap(); + docs::with_pages(data, &book_file, &page_files, out.path(), None).unwrap(); let said = std::fs::read_to_string(out.path().join("Libro").join("Libro.md")).unwrap(); assert!(said.contains("[Uno](<01 Uno.md>)"), "{said}"); @@ -838,7 +848,7 @@ fn a_book_of_more_than_ninety_nine_pages_still_comes_out_in_reading_order() { .map(|one| one.file.clone()) .collect(); let out = tmp(); - docs::with_pages(data, &book_file, &page_files, out.path()).unwrap(); + docs::with_pages(data, &book_file, &page_files, out.path(), None).unwrap(); let folder = out.path().join("Tomo"); let mut names: Vec = std::fs::read_dir(&folder) @@ -1011,7 +1021,7 @@ fn exporting_a_book_whose_pages_name_each_other_rewrites_both_sides_of_the_cross .map(|one| one.file.clone()) .collect(); let out = tmp(); - docs::with_pages(data, &book_file, &page_files, out.path()).unwrap(); + docs::with_pages(data, &book_file, &page_files, out.path(), None).unwrap(); let folder = out.path().join("Libro"); let said_uno = std::fs::read_to_string(folder.join("01 Uno.md")).unwrap(); @@ -1053,7 +1063,7 @@ fn a_picture_the_person_wrote_is_still_a_picture_when_the_book_comes_out() { .map(|one| one.file.clone()) .collect(); let out = tmp(); - docs::with_pages(data, &book_file, &page_files, out.path()).unwrap(); + docs::with_pages(data, &book_file, &page_files, out.path(), None).unwrap(); let said = std::fs::read_to_string(out.path().join("Libro").join("Libro.md")).unwrap(); assert!( diff --git a/crates/tisty-core/tests/parcelled.rs b/crates/tisty-core/tests/parcelled.rs new file mode 100644 index 00000000..4a62f8c1 --- /dev/null +++ b/crates/tisty-core/tests/parcelled.rs @@ -0,0 +1,645 @@ +use std::path::{Path, PathBuf}; + +use tisty_core::event::{DocAdd, FolderAdd, Said}; +use tisty_core::model::{DocId, FolderId}; +use tisty_core::parcel::Along; +use tisty_core::{DeviceId, Event, Op, State, attach, docs, order, parcel}; +use ulid::Ulid; + +fn tmp() -> tempfile::TempDir { + tempfile::tempdir().unwrap() +} + +fn device(name: &str) -> DeviceId { + DeviceId(name.into()) +} + +fn at(ms: i64) -> jiff::Timestamp { + jiff::Timestamp::from_millisecond(ms).unwrap() +} + +struct Room { + data: PathBuf, + state: State, + dev: DeviceId, + seq: i64, +} + +impl Room { + fn new(under: &Path, named: &str) -> Self { + let data = under.join(named); + std::fs::create_dir_all(data.join("docs")).unwrap(); + Self { + data, + state: State::default(), + dev: device(named), + seq: 0, + } + } + + fn tell(&mut self, op: Op) { + self.seq += 1; + let event = Event::new(self.dev.clone(), at(self.seq), op); + self.state.apply(&event); + } + + fn folder(&mut self, name: &str, parent: Option, icon: &str) -> FolderId { + let id = Ulid::generate(); + let order = order::last_of( + self.state + .under(parent) + .iter() + .map(|one| one.order.as_str()), + ); + self.tell(Op::FolderAdd { + id, + d: FolderAdd { + name: name.into(), + order, + parent, + icon: Some(icon.into()), + color: Some("teal".into()), + }, + }); + id + } + + fn doc( + &mut self, + body: &str, + folder: Option, + page_of: Option, + ) -> (DocId, String) { + let made = docs::create(&self.data.join("docs"), &self.dev, body).unwrap(); + let id = Ulid::generate(); + let order = order::last_of( + self.state + .docs + .values() + .filter(|one| one.folder == folder) + .map(|one| one.order.as_str()), + ); + self.tell(Op::DocAdd { + id, + d: DocAdd { + file: made.id.clone(), + order, + said: Some(Said { + title: made.title.clone(), + bytes: None, + tags: Some(Vec::new()), + }), + folder, + page_of, + }, + }); + (id, made.id) + } + + fn take_in(&mut self, from: &Path) -> parcel::Landed { + let (landed, ops) = parcel::read( + &self.data, + &self.state, + &self.dev.clone(), + from, + &Along::default(), + ) + .unwrap(); + for op in ops { + self.tell(op); + } + landed + } + + fn titled(&self, name: &str) -> &tisty_core::model::Kept { + self.state + .docs + .values() + .find(|one| one.title.as_deref() == Some(name)) + .unwrap_or_else(|| panic!("no document called {name}")) + } + + fn shelf(&self, name: &str) -> &tisty_core::model::Folder { + self.state + .folders + .values() + .find(|one| one.name == name) + .unwrap_or_else(|| panic!("no folder called {name}")) + } + + fn body(&self, file: &str) -> String { + docs::read(&self.data.join("docs"), file).unwrap() + } +} + +fn filled(room: &mut Room) -> PathBuf { + let shed = room.data.join("attachments").join("ab"); + std::fs::create_dir_all(&shed).unwrap(); + std::fs::write(shed.join("plano-91f2ab00.png"), b"a picture").unwrap(); + + let personal = room.folder("Personal", None, "home"); + let casa = room.folder("Casa", Some(personal), "build"); + + let (book, book_file) = room.doc("# Obra\n\ntexto", Some(casa), None); + let (_, page_file) = room.doc( + "# Plano\n\n![el plano]()", + None, + Some(book), + ); + docs::write( + &room.data.join("docs"), + &book_file, + &format!("# Obra\n\ntexto\n\n![Plano](tisty:doc/{page_file})"), + ) + .unwrap(); + + let (locked, _) = room.doc("# Guardado\n\nno se toca", Some(personal), None); + room.tell(Op::DocLock { id: locked }); + let (away, _) = room.doc("# Terminado\n\nya esta", Some(personal), None); + room.tell(Op::DocArchive { id: away }); + + room.data.parent().unwrap().join("todo.tistydoc") +} + +#[test] +fn everything_written_travels_to_another_tisty_and_lands_as_its_own() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + let box_at = filled(&mut here); + + let sent = parcel::write(&here.data, &here.state, &[], &box_at, &Along::default()).unwrap(); + assert_eq!( + (sent.docs, sent.pages, sent.folders, sent.files), + (3, 1, 2, 1) + ); + + let mut there = Room::new(room.path(), "theirs"); + let landed = there.take_in(&box_at); + + assert_eq!((landed.docs, landed.pages, landed.folders), (3, 1, 2)); + assert_eq!(landed.files, 1); + assert_eq!(landed.missed, 0); + + let casa = there.shelf("Casa"); + assert_eq!(casa.parent, Some(there.shelf("Personal").id)); + assert_eq!(casa.icon.as_deref(), Some("build")); + assert_eq!(casa.color.as_deref(), Some("teal")); + + let obra = there.titled("Obra"); + assert_eq!(obra.folder, Some(casa.id)); + let plano = there.titled("Plano"); + assert_eq!(plano.page_of, Some(obra.id)); + assert!(there.titled("Guardado").locked); + assert!(there.titled("Terminado").archived); +} + +#[test] +fn what_a_document_points_at_still_points_at_it_under_its_new_name() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + let box_at = filled(&mut here); + parcel::write(&here.data, &here.state, &[], &box_at, &Along::default()).unwrap(); + + let mut there = Room::new(room.path(), "theirs"); + there.take_in(&box_at); + + let obra = there.titled("Obra"); + let plano = there.titled("Plano"); + let said = there.body(&obra.file); + assert!( + said.contains(&format!("tisty:doc/{}", plano.file)), + "the page card was left pointing at a name this store never had: {said}" + ); + assert!(!said.contains("mine-"), "{said}"); + + let carried = there.body(&plano.file); + let at = carried + .split_once("](<") + .and_then(|(_, rest)| rest.split_once(">)")) + .map(|(at, _)| at) + .unwrap(); + assert_eq!( + std::fs::read(attach::resolve(at, &there.data).unwrap()).unwrap(), + b"a picture" + ); +} + +#[test] +fn a_parcel_carries_the_writing_and_not_one_line_of_the_log() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + let box_at = filled(&mut here); + parcel::write(&here.data, &here.state, &[], &box_at, &Along::default()).unwrap(); + + let file = std::fs::File::open(&box_at).unwrap(); + let mut zip = zip::ZipArchive::new(file).unwrap(); + let inside: Vec = (0..zip.len()) + .map(|i| zip.by_index(i).unwrap().name().to_string()) + .collect(); + + assert!( + !inside.iter().any(|one| one.starts_with("store/")), + "{inside:?}" + ); + assert!(inside.iter().any(|one| one == "tisty-docs.json")); + assert!(inside.iter().filter(|one| one.starts_with("docs/")).count() == 4); +} + +#[test] +fn a_folder_that_is_already_there_takes_the_documents_in_rather_than_standing_beside_itself() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + let box_at = filled(&mut here); + parcel::write(&here.data, &here.state, &[], &box_at, &Along::default()).unwrap(); + + let mut there = Room::new(room.path(), "theirs"); + let personal = there.folder("personal ", None, "home"); + there.doc("# Suyo\n\nya estaba", Some(personal), None); + + let landed = there.take_in(&box_at); + + assert_eq!(landed.joined, 1, "it did not recognise the folder by name"); + assert_eq!(landed.folders, 1); + assert_eq!( + there + .state + .folders + .values() + .filter(|one| one.parent.is_none()) + .count(), + 1, + "a second Personal was made beside the first" + ); + assert_eq!(there.titled("Guardado").folder, Some(personal)); + assert_eq!(there.shelf("Casa").parent, Some(personal)); +} + +#[test] +fn one_document_can_travel_alone_and_its_pages_go_with_it() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + let box_at = filled(&mut here); + let obra = here.titled("Obra").file.clone(); + + let sent = parcel::write(&here.data, &here.state, &[obra], &box_at, &Along::default()).unwrap(); + + assert_eq!((sent.docs, sent.pages), (1, 1)); + let mut there = Room::new(room.path(), "theirs"); + let landed = there.take_in(&box_at); + assert_eq!((landed.docs, landed.pages), (1, 1)); + assert_eq!(there.state.docs.len(), 2); +} + +#[test] +fn a_file_that_is_not_in_the_store_is_named_rather_than_carried_in_silence() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + here.doc( + "# Sola\n\n![un video]()", + None, + None, + ); + + let box_at = room.path().join("una.tistydoc"); + let sent = parcel::write(&here.data, &here.state, &[], &box_at, &Along::default()).unwrap(); + + assert_eq!(sent.files, 0); + assert_eq!(sent.left, ["attachments/6d/clip-da1d77da.mov"]); +} + +#[test] +fn everything_written_plainly_stands_in_the_folders_it_was_kept_in() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + filled(&mut here); + + let out = room.path().join("plano"); + let sent = parcel::plainly(&here.data, &here.state, &[], &out, &Along::default()).unwrap(); + + assert_eq!((sent.docs, sent.pages, sent.files), (3, 1, 1)); + assert_eq!(sent.folders, 2); + let obra = out.join("Personal").join("Casa").join("Obra"); + assert!(obra.join("Obra.md").is_file(), "{obra:?}"); + assert!(obra.join("01 Plano.md").is_file()); + assert!( + obra.join("attachments") + .join("ab") + .join("plano-91f2ab00.png") + .is_file() + ); + assert!( + out.join("Personal") + .join("Guardado") + .join("Guardado.md") + .is_file() + ); + assert!( + out.join("Personal") + .join("Terminado") + .join("Terminado.md") + .is_file(), + "an archived document was left out of what says it takes everything" + ); +} + +#[test] +fn two_documents_called_the_same_thing_do_not_write_over_each_other() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + here.doc("# Acta\n\nla primera", None, None); + here.doc("# Acta\n\nla segunda", None, None); + + let out = room.path().join("plano"); + let sent = parcel::plainly(&here.data, &here.state, &[], &out, &Along::default()).unwrap(); + + assert_eq!(sent.docs, 2); + assert_eq!( + std::fs::read_to_string(out.join("Acta").join("Acta.md")).unwrap(), + "# Acta\n\nla primera\n" + ); + assert_eq!( + std::fs::read_to_string(out.join("Acta 2").join("Acta 2.md")).unwrap(), + "# Acta\n\nla segunda\n" + ); +} + +#[test] +fn what_is_too_heavy_to_keep_here_is_carried_from_the_folder_everyone_shares() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + here.doc( + "# Charla\n\n![el video]()", + None, + None, + ); + let shared = room.path().join("drive"); + let shelf = shared.join("attachments").join("6d"); + std::fs::create_dir_all(&shelf).unwrap(); + std::fs::write(shelf.join("clip-da1d77da.mov"), b"a heavy video").unwrap(); + + let box_at = room.path().join("con-video.tistydoc"); + let sent = parcel::write( + &here.data, + &here.state, + &[], + &box_at, + &Along { + also: Some(&shared), + ..Along::default() + }, + ) + .unwrap(); + + assert_eq!(sent.files, 1, "the video was left behind: {:?}", sent.left); + assert!(sent.left.is_empty()); + + let mut there = Room::new(room.path(), "theirs"); + there.take_in(&box_at); + let charla = there.titled("Charla"); + let said = there.body(&charla.file); + let at = said + .split_once("](<") + .and_then(|(_, rest)| rest.split_once(">)")) + .map(|(at, _)| at) + .unwrap(); + assert_eq!( + std::fs::read(attach::resolve(at, &there.data).unwrap()).unwrap(), + b"a heavy video" + ); +} + +#[test] +fn a_long_carry_says_how_far_along_it_is_rather_than_going_quiet() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + let box_at = filled(&mut here); + + let steps = std::cell::RefCell::new(Vec::new()); + let sent = parcel::write( + &here.data, + &here.state, + &[], + &box_at, + &Along { + say: Some(&|step| steps.borrow_mut().push((step.done, step.whole))), + ..Along::default() + }, + ) + .unwrap(); + + let told = steps.into_inner(); + assert_eq!(told.len(), sent.docs + sent.pages + sent.files); + assert!(told.iter().all(|(done, whole)| done <= whole), "{told:?}"); + assert_eq!(told.last(), Some(&(5, 5)), "{told:?}"); +} + +#[test] +fn a_parcel_is_never_written_into_the_store_it_came_from() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + here.doc("# Sola\n\nnada mas", None, None); + + assert!( + parcel::write( + &here.data, + &here.state, + &[], + &here.data.join("una.tistydoc"), + &Along::default() + ) + .is_err() + ); +} + +#[test] +fn what_is_not_a_parcel_is_turned_away_rather_than_half_read() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + here.doc("# Sola\n\nnada mas", None, None); + + let stray = room.path().join("cualquiera.tistydoc"); + std::fs::write(&stray, b"not a zip at all").unwrap(); + assert!( + parcel::read( + &here.data, + &here.state, + &here.dev.clone(), + &stray, + &Along::default() + ) + .is_err() + ); +} + +#[test] +fn a_title_that_names_a_device_or_a_path_becomes_a_folder_both_systems_can_hold() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + for said in [ + "# CON\n\nen Windows esto es un dispositivo", + "# ../../fuera\n\nsubir por el arbol", + "# C:\\Windows\\System32\n\nuna ruta entera", + "# nombre.\n\ntermina en punto", + "# año 2026: qué tal ✅\n\nacentos y emoji", + ] { + here.doc(said, None, None); + } + + let out = room.path().join("plano"); + let sent = parcel::plainly(&here.data, &here.state, &[], &out, &Along::default()).unwrap(); + + assert_eq!(sent.docs, 5, "left behind: {:?}", sent.left); + let made: Vec = std::fs::read_dir(&out) + .unwrap() + .filter_map(|one| one.ok()) + .map(|one| one.file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(made.len(), 5, "{made:?}"); + for one in &made { + assert!(!one.contains(['/', '\\', ':']), "{one}"); + assert!(!one.starts_with('.'), "{one}"); + assert!(!one.ends_with('.') && !one.ends_with(' '), "{one}"); + } + assert!( + std::fs::read_dir(room.path()) + .unwrap() + .filter_map(|one| one.ok()) + .all(|one| one.file_name() != "fuera"), + "a title climbed out of the folder it was given" + ); +} + +#[test] +fn a_parcel_from_a_newer_tisty_is_turned_away_rather_than_half_understood() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + here.doc("# Sola\n\nnada mas", None, None); + let box_at = room.path().join("nueva.tistydoc"); + parcel::write(&here.data, &here.state, &[], &box_at, &Along::default()).unwrap(); + + let said = std::fs::read(&box_at).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(said)).unwrap(); + let ahead = room.path().join("ahead.tistydoc"); + let mut out = zip::ZipWriter::new(std::fs::File::create(&ahead).unwrap()); + for i in 0..zip.len() { + let mut held = zip.by_index(i).unwrap(); + let named = held.name().to_string(); + let mut body = Vec::new(); + std::io::Read::read_to_end(&mut held, &mut body).unwrap(); + if named == "tisty-docs.json" { + let said = String::from_utf8(body) + .unwrap() + .replace("\"version\": 1", "\"version\": 99"); + body = said.into_bytes(); + } + out.start_file(named, zip::write::SimpleFileOptions::default()) + .unwrap(); + std::io::Write::write_all(&mut out, &body).unwrap(); + } + out.finish().unwrap(); + + let mut there = Room::new(room.path(), "theirs"); + let refused = parcel::read( + &there.data, + &there.state, + &there.dev.clone(), + &ahead, + &Along::default(), + ); + assert!(refused.is_err()); + assert!(there.state.docs.is_empty()); + there.seq += 1; +} + +#[test] +fn a_landing_that_never_finished_is_swept_by_the_next_one() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + let box_at = filled(&mut here); + parcel::write(&here.data, &here.state, &[], &box_at, &Along::default()).unwrap(); + + let mut there = Room::new(room.path(), "theirs"); + let stale = there.data.join(".landing-999999"); + std::fs::create_dir_all(stale.join("attachments")).unwrap(); + std::fs::write(stale.join("attachments").join("big.mp4"), b"left over").unwrap(); + + there.take_in(&box_at); + + assert!( + !stale.exists(), + "the leftovers of an interrupted landing stayed" + ); + assert!( + std::fs::read_dir(&there.data) + .unwrap() + .filter_map(|one| one.ok()) + .all(|one| !one.file_name().to_string_lossy().starts_with(".landing-")), + "a landing folder outlived the landing" + ); +} + +#[test] +fn a_page_whose_document_never_arrived_is_counted_rather_than_hung_from_nothing() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + let (book, _) = here.doc("# Libro\n\ntexto", None, None); + here.doc("# Capitulo\n\nuno", None, Some(book)); + + let box_at = room.path().join("solo-la-pagina.tistydoc"); + let page = here + .state + .docs + .values() + .find(|one| one.page_of.is_some()) + .unwrap() + .file + .clone(); + parcel::write(&here.data, &here.state, &[page], &box_at, &Along::default()).unwrap(); + + let mut there = Room::new(room.path(), "theirs"); + let landed = there.take_in(&box_at); + + assert_eq!((landed.docs, landed.pages), (1, 0)); + assert!(there.titled("Capitulo").page_of.is_none()); +} + +#[test] +fn a_deep_tree_with_long_names_still_lands_on_a_system_that_counts_its_path_characters() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + let long = "carpeta-de-nombre-larguisimo-para-medir"; + let mut at = None; + for _ in 0..4 { + at = Some(here.folder(long, at, "home")); + } + let titled = "titulo tan largo como Tisty permite antes de cortarlo por lo sano y algo mas"; + let shed = here.data.join("attachments").join("ab"); + std::fs::create_dir_all(&shed).unwrap(); + let named = "un-nombre-de-adjunto-francamente-larguisimo-91f2ab00.png"; + std::fs::write(shed.join(named), b"a picture").unwrap(); + here.doc( + &format!("# {titled}\n\n![una foto]()"), + at, + None, + ); + + let out = room + .path() + .join("una carpeta de salida con su propio nombre largo") + .join("y otra dentro"); + let sent = parcel::plainly(&here.data, &here.state, &[], &out, &Along::default()).unwrap(); + + assert_eq!(sent.docs, 1, "left behind: {:?}", sent.left); + assert_eq!(sent.files, 1); + let deep = (0..4).fold(out, |at, _| at.join(long)); + let folder = std::fs::read_dir(&deep) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + assert!( + folder.join("attachments").join("ab").join(named).is_file(), + "{folder:?}" + ); +} From e29162ac703ba715069907827cfd80f9543d60d1 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 6 Sep 2026 20:54:17 -0300 Subject: [PATCH 3/8] feat: a document says who wrote it, and keeps saying it when it travels A store signs itself once with an alias that lives in its own log, and every document is sealed with the name in force when it was written: changing the alias later leaves yesterday alone and offers, once and by hand, to re-sign what came before. What arrived from somebody else keeps their name whatever you call yourself, coming home under your own name is not coming home a guest, and the guide is signed by Tisty. Archiving a document closes it to writing, from the window, the terminal and an assistant alike. --- app/src-tauri/src/lib.rs | 145 ++++++++- app/src/App.tsx | 3 + app/src/core.ts | 15 + app/src/locales.ts | 76 +++++ app/src/refusal.ts | 2 + app/src/tests/beside.test.tsx | 64 +++- app/src/tests/keeping.test.tsx | 137 ++++++++- app/src/ui/Beside.tsx | 18 ++ app/src/ui/Docs.tsx | 87 +++++- app/src/ui/Keeping.tsx | 125 +++++++- app/src/ui/Welcome.tsx | 97 +++++- app/src/ui/paper.tsx | 18 +- crates/tisty-cli/src/app.rs | 4 + crates/tisty-cli/src/cmd/data.rs | 2 + crates/tisty-cli/src/cmd/demo.rs | 2 + crates/tisty-cli/src/mcp.rs | 2 + crates/tisty-core/src/cache.rs | 4 + crates/tisty-core/src/event.rs | 9 +- crates/tisty-core/src/event/op.rs | 35 ++- crates/tisty-core/src/model/folder.rs | 18 ++ crates/tisty-core/src/parcel.rs | 8 + crates/tisty-core/src/state.rs | 94 +++++- crates/tisty-core/src/tagging.rs | 6 + crates/tisty-core/src/tidy.rs | 6 + crates/tisty-core/src/undo.rs | 8 + crates/tisty-core/tests/pages.rs | 16 + crates/tisty-core/tests/pages_files.rs | 12 + crates/tisty-core/tests/parcelled.rs | 389 +++++++++++++++++++++++++ crates/tisty-core/tests/tagged.rs | 2 + crates/tisty-core/tests/telling.rs | 4 + crates/tisty-sync/src/lib.rs | 4 + 31 files changed, 1384 insertions(+), 28 deletions(-) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 721cd5cd..b9eb5d4f 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -328,6 +328,8 @@ impl Session { self.commit(Op::DocAdd { id: ulid::Ulid::generate(), d: tisty_core::event::DocAdd { + made: None, + by: None, file: file.to_string(), order, said: Some(tisty_core::event::Said::of(&body)), @@ -2037,6 +2039,8 @@ const REFUSALS: &[&str] = &[ "sandboxCannotMerge", "noSuchDoc", "notAParcel", + "stillPacking", + "aliasTooLong", "tooBig", "noSuchIcon", "noSuchColour", @@ -3167,6 +3171,9 @@ struct Facts { wrote: Option, bytes: u64, pages: usize, + author: Option, + editor: Option, + born: Option, } fn seconds(at: std::io::Result) -> Option { @@ -3187,13 +3194,103 @@ 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 as_signed(session: &Session) -> Signed { + Signed { + alias: session.state.signed.alias.clone(), + before: session.state.signed_before.iter().rev().cloned().collect(), + 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| 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); + if said == session.state.signed.alias { + 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))?; @@ -3202,9 +3299,14 @@ fn doc_facts(session: tauri::State<'_, Mutex>, id: String) -> Answer>, - alone: tauri::State<'_, OneAtATime>, + alone: tauri::State<'_, Packing>, which: Vec, into: String, ) -> Answer { @@ -3829,7 +3939,7 @@ async fn docs_pack( async fn docs_take_out( app: tauri::AppHandle, session: tauri::State<'_, Mutex>, - alone: tauri::State<'_, OneAtATime>, + alone: tauri::State<'_, Packing>, which: Vec, into: String, ) -> Answer { @@ -3882,7 +3992,7 @@ async fn docs_take_out( async fn docs_unpack( app: tauri::AppHandle, session: tauri::State<'_, Mutex>, - alone: tauri::State<'_, OneAtATime>, + alone: tauri::State<'_, Packing>, from: String, ) -> Answer { let _done = alone.inner().taken()?; @@ -3967,6 +4077,8 @@ fn doc_import( session.commit(Op::DocAdd { id: ulid::Ulid::generate(), d: tisty_core::event::DocAdd { + made: None, + by: None, file: made.id.clone(), order, said: Some(tisty_core::event::Said { @@ -4037,6 +4149,8 @@ fn doc_new( session.commit(Op::DocAdd { id: ulid::Ulid::generate(), d: tisty_core::event::DocAdd { + made: None, + by: None, file: made.id.clone(), order, said: Some(tisty_core::event::Said { @@ -4992,6 +5106,8 @@ fn settle_paper( .commit(Op::DocAdd { id: ulid::Ulid::generate(), d: tisty_core::event::DocAdd { + made: None, + by: None, file: file.clone(), folder, order, @@ -5672,6 +5788,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); @@ -5890,6 +6015,7 @@ pub fn run() { } }) .manage(OneAtATime::default()) + .manage(Packing::default()) .manage(Updating::default()) .manage(Leaving::default()) .invoke_handler(tauri::generate_handler![ @@ -5997,6 +6123,9 @@ pub fn run() { doc_drop, doc_import, doc_export, + signed, + sign, + sign_the_rest, docs_pack, docs_take_out, docs_unpack, @@ -6046,6 +6175,8 @@ mod deleting { .commit(Op::DocAdd { id: ulid::Ulid::generate(), d: tisty_core::event::DocAdd { + made: None, + by: None, said: None, file: made.id.clone(), order: tisty_core::order::first(), @@ -6933,6 +7064,8 @@ mod ordering { .commit(Op::DocAdd { id: ulid::Ulid::generate(), d: tisty_core::event::DocAdd { + made: None, + by: None, file: file.clone(), order: "a1".into(), said: Some(tisty_core::event::Said { @@ -6960,6 +7093,8 @@ mod ordering { .commit(Op::DocAdd { id: ulid::Ulid::generate(), d: tisty_core::event::DocAdd { + made: None, + by: None, file: "notas-c3d4".into(), order: "a1".into(), said: Some(tisty_core::event::Said { @@ -7045,6 +7180,8 @@ mod ordering { .commit(Op::DocAdd { id, d: tisty_core::event::DocAdd { + 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 5649f30b..3c71d6c9 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -762,6 +762,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: "✕", diff --git a/app/src/core.ts b/app/src/core.ts index 512af004..5597e886 100644 --- a/app/src/core.ts +++ b/app/src/core.ts @@ -682,8 +682,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"); diff --git a/app/src/locales.ts b/app/src/locales.ts index d4d0bd70..21bb3638 100644 --- a/app/src/locales.ts +++ b/app/src/locales.ts @@ -353,6 +353,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", @@ -443,6 +446,8 @@ const en = { 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,6 +477,13 @@ 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", @@ -534,6 +546,7 @@ const en = { landedNone: "Nothing came in: the parcel held no documents", landedShort: "It came in, but {name} of the files it points at did not fit and stayed out", notAParcel: "{name} is not a Tisty parcel", + 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}", @@ -961,6 +974,21 @@ const en = { tabUpkeep: "Maintenance", tabAgents: "Assistants", bandWindow: "This window", + bandSigning: "Who writes here", + alias: "Alias", + aliasShort: "What you sign your writing with.", + aliasNone: "unsigned", + aliasRest: "Sign the older ones", + aliasNow: "You now sign as {name}", + aliasRestAsk: + "What was already written keeps the signature it had. Do you want the {name} older documents 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", + 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 — and only the person you share a document with ever sees it, whether you hand it over as markdown, as a PDF or as a Tisty parcel.\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", @@ -1144,6 +1172,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 — and only the person you share a document with ever sees it, whether you hand it over as markdown, as a PDF or as a Tisty parcel.", + 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: "rgdevment", welcomeRedundancy: "Syncing gives you redundancy, not a way back in time: delete a task and the deletion travels too.", }; @@ -1507,6 +1545,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", @@ -1598,6 +1639,8 @@ const es: Catalog = { 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: @@ -1628,6 +1671,13 @@ 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", @@ -1690,6 +1740,7 @@ const es: Catalog = { landedNone: "No entró nada: el paquete no traía documentos", landedShort: "Entró, pero {name} de los archivos a los que apunta no cupieron y quedaron fuera", notAParcel: "{name} no es un paquete de Tisty", + 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}", @@ -2117,6 +2168,21 @@ const es: Catalog = { tabUpkeep: "Mantenimiento", tabAgents: "Asistentes", bandWindow: "Esta ventana", + bandSigning: "Quién escribe aquí", + alias: "Alias", + aliasShort: "Con lo que firmas lo que escribes.", + aliasNone: "sin firmar", + aliasRest: "Firmar los anteriores", + aliasNow: "Ahora firmas como {name}", + aliasRestAsk: + "Lo que ya estaba escrito conserva la firma que tenía. ¿Quieres que los {name} documentos anteriores pasen 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", + 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—, y solo lo ve aquella persona con quien compartas un documento, no importa si lo compartes como markdown, PDF o formato Tisty.\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", @@ -2301,6 +2367,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—, y solo lo ve aquella persona con quien compartas un documento, no importa si lo compartes como markdown, PDF o formato Tisty.", + 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: "rgdevment", 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 f969f9f8..42a26095 100644 --- a/app/src/refusal.ts +++ b/app/src/refusal.ts @@ -66,6 +66,8 @@ const KNOWN = [ "noSuchFolder", "noSuchDoc", "notAParcel", + "stillPacking", + "aliasTooLong", "tooBig", "deleteRefused", "alreadyKept", diff --git a/app/src/tests/beside.test.tsx b/app/src/tests/beside.test.tsx index bc3e5e60..988ad219 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 c986e8b0..1c0bb6d9 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": { @@ -1244,6 +1272,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 +1438,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,12 +1478,91 @@ 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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={() => {}} />); await ready(); diff --git a/app/src/ui/Beside.tsx b/app/src/ui/Beside.tsx index c2fa3a84..a24125d5 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 22f53a20..97f29676 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) => { @@ -324,6 +332,7 @@ export default function Docs({ const own = filed(known, open?.file); const bolted = Boolean(own?.locked); + const shelved = Boolean(own?.archived); 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,7 +401,19 @@ 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 () => { @@ -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")} + +
+ )} + {bolted && !shelved && open && (
)} + {pdfAsked && open && ( + setPdfAsked(false)}> +

{t("pdfSignWhy")}

+ +
+ + +
+
+ )} + {beside && open && ( (); const [trouble, setTrouble] = useState(); const [told, setTold] = useState({ names: false, paths: false, logs: true }); + const [alias, setAlias] = useState(""); + 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 ?? ""); + setAliases(one.before); + setMine(one.mine); + }) + .catch(() => {}); syncState() .then(setState) .catch((e) => setTrouble({ card: "sync", text: saidPlainly(e) })); @@ -531,6 +548,37 @@ export default function Keeping({ onChanged, onGreet, onDoc, greeted }: Props) { return (
+ {asking && ( + setAsking(null)}> +

+ {fill("aliasRestAsk", String(mine))} +

+

{t("aliasRestNever")}

+
+ + +
+
+ )} {picking && ( setPicking(false)}>

{t("keepersWhy")}

@@ -654,6 +702,60 @@ export default function Keeping({ onChanged, onGreet, onDoc, greeted }: Props) { /> + +
+ + {t("alias")} + + + } + why={t("aliasShort")} + which="signing" + said={said} + trouble={trouble} + > + setAlias(e.target.value)} + onBlur={() => { + const said = alias.trim(); + run("signing", sign(said || undefined), (now) => { + setAlias(now.alias ?? ""); + setAliases(now.before); + setMine(now.mine); + if (now.alias && now.mine > 0) setAsking(now.alias); + }); + }} + list={before.length > 0 ? "signed-before" : undefined} + className={`w-40 rounded-[7px] border border-line bg-bg px-2 py-1 text-[12.5px] ${off}`} + /> + {mine > 0 && ( + + )} + {before.length > 0 && ( + + {before.map((one) => ( + + )} + +
+
{kept && @@ -1735,6 +1837,26 @@ function Band({ label }: { label: string }) { ); } +function Ask({ said }: { said: string }) { + return ( + + + + {said} + + + ); +} + function Line({ title, why, @@ -1744,7 +1866,7 @@ function Line({ children, more, }: { - title: string; + title: React.ReactNode; why?: React.ReactNode; which: Which; said?: Word; @@ -1824,6 +1946,7 @@ interface CardProps { const NAMED: Record[0]> = { sync: "syncing", + signing: "alias", backup: "backup", restore: "restoreTitle", review: "review", diff --git a/app/src/ui/Welcome.tsx b/app/src/ui/Welcome.tsx index 18f65cdf..ecd798f4 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("welcomeSigningNote")}

+ + ) : step === "tongue" ? ( TONGUES.map((one) => ( setStep("tongue")} + onClick={() => setStep("signing")} className="text-faint hover:text-ink disabled:opacity-60" > {t("welcomeBack")} )} + {step === "signing" && ( + <> + + + + )} {step === "tongue" && (

{t("attachBig")}

+ +
+ + + + + + +
+ {state.backsUp && ( <> @@ -1947,6 +1994,7 @@ 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 d1f8310c..9e8ab278 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/crates/tisty-cli/src/app.rs b/crates/tisty-cli/src/app.rs index 32e9c836..2cc16f28 100644 --- a/crates/tisty-cli/src/app.rs +++ b/crates/tisty-cli/src/app.rs @@ -296,6 +296,7 @@ mod undoing { app.commit(Op::DocAdd { id: book, d: tisty_core::event::DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -311,6 +312,7 @@ mod undoing { app.commit(Op::DocAdd { id: *id, d: tisty_core::event::DocAdd { + wrote: None, guest: false, said: None, made: None, diff --git a/crates/tisty-cli/src/cmd/data.rs b/crates/tisty-cli/src/cmd/data.rs index cb8bbbd1..a6dcea55 100644 --- a/crates/tisty-cli/src/cmd/data.rs +++ b/crates/tisty-cli/src/cmd/data.rs @@ -129,6 +129,7 @@ pub fn doc( app.commit(tisty_core::Op::DocAdd { id: ulid::Ulid::generate(), d: tisty_core::event::DocAdd { + wrote: None, guest: false, made: None, by: None, diff --git a/crates/tisty-cli/src/cmd/demo.rs b/crates/tisty-cli/src/cmd/demo.rs index c9072ca4..b5ee57c6 100644 --- a/crates/tisty-cli/src/cmd/demo.rs +++ b/crates/tisty-cli/src/cmd/demo.rs @@ -293,6 +293,7 @@ fn papers(app: &App, lang: Lang) -> 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, diff --git a/crates/tisty-cli/src/mcp.rs b/crates/tisty-cli/src/mcp.rs index ff13fc13..ea2dbec5 100644 --- a/crates/tisty-cli/src/mcp.rs +++ b/crates/tisty-cli/src/mcp.rs @@ -1273,6 +1273,7 @@ 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(), diff --git a/crates/tisty-core/src/cache.rs b/crates/tisty-core/src/cache.rs index c1b4f200..5ef3ddbb 100644 --- a/crates/tisty-core/src/cache.rs +++ b/crates/tisty-core/src/cache.rs @@ -831,6 +831,7 @@ mod tests { .append(Op::DocAdd { id, d: crate::event::DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -878,6 +879,7 @@ mod tests { .append(Op::DocAdd { id: Ulid::generate(), d: crate::event::DocAdd { + wrote: None, guest: false, made: None, by: None, diff --git a/crates/tisty-core/src/docs.rs b/crates/tisty-core/src/docs.rs index fd559612..a0136ea9 100644 --- a/crates/tisty-core/src/docs.rs +++ b/crates/tisty-core/src/docs.rs @@ -823,7 +823,7 @@ fn laid_out( Ok(taken) } -pub(crate) fn spelled(said: &str) -> String { +pub fn spelled(said: &str) -> String { let flat: String = said .chars() .map(|c| { diff --git a/crates/tisty-core/src/event/op.rs b/crates/tisty-core/src/event/op.rs index c07e0b85..07e24832 100644 --- a/crates/tisty-core/src/event/op.rs +++ b/crates/tisty-core/src/event/op.rs @@ -593,6 +593,8 @@ pub struct DocAdd { #[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, diff --git a/crates/tisty-core/src/parcel.rs b/crates/tisty-core/src/parcel.rs index c747ee24..cf8ff677 100644 --- a/crates/tisty-core/src/parcel.rs +++ b/crates/tisty-core/src/parcel.rs @@ -21,6 +21,7 @@ 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 { @@ -186,7 +187,12 @@ fn filled( .map(|(one, body)| Paper { file: one.file.clone(), order: one.order.clone(), - title: Some(crate::docs::titled(body)), + 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 @@ -202,6 +208,11 @@ fn filled( .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); @@ -250,7 +261,10 @@ fn filled( sent.folders = manifest.folders.len(); zip.finish().map_err(zipped)?; - Ok(sent) + match sent.docs + sent.pages { + 0 => Err(Error::NothingToCarry), + _ => Ok(sent), + } } pub fn plainly( @@ -322,14 +336,12 @@ pub fn plainly( 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())]; - let mut deep = 0; while let Some((parent, at)) = left.pop() { - deep += 1; - if deep > DEEPEST * DEEPEST { - break; - } 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 { @@ -465,12 +477,13 @@ 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-")); + .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, @@ -585,6 +598,7 @@ fn taken_in( ops.push(Op::DocAdd { id, d: DocAdd { + wrote: None, file: made.id.clone(), order, made: paper.made, @@ -790,7 +804,10 @@ fn brought( continue; } if !carried.contains_key(&one) { - let Some(at) = safe(&one) else { + let Ok(said) = crate::attach::resolve(&one, Path::new("")) else { + continue; + }; + let Some(at) = safe(&said.to_string_lossy().replace(char::from(92), "/")) else { continue; }; let from = staged.join(at); diff --git a/crates/tisty-core/src/state.rs b/crates/tisty-core/src/state.rs index 6eb65c71..7d572b24 100644 --- a/crates/tisty-core/src/state.rs +++ b/crates/tisty-core/src/state.rs @@ -98,6 +98,12 @@ impl State { self.shut(id) || self.docs.get(&id).is_some_and(|one| one.archived) } + pub fn shut_tight(&self, file: &str) -> bool { + self.docs + .values() + .any(|one| one.file == file && self.shut(one.id)) + } + pub fn bolted(&self, file: &str) -> bool { self.docs .values() @@ -300,7 +306,7 @@ impl State { order: d.order.clone(), title: d.said.as_ref().map(|one| one.title.clone()), bytes: d.said.as_ref().and_then(|one| one.bytes), - wrote: Some(d.made.unwrap_or(event.timestamp)), + wrote: Some(d.wrote.or(d.made).unwrap_or(event.timestamp)), made: Some(d.made.unwrap_or(event.timestamp)), made_by: Some(event.device.clone()), wrote_by: Some(event.device.clone()), @@ -1212,8 +1218,7 @@ impl State { let now = self.signed.alias.as_deref(); self.docs .values() - .filter(|one| !self.written_shut(one.id)) - .filter(|one| !one.guest || alike(one.by.as_deref(), now)) + .filter(|one| !self.written_shut(one.id) && !one.guest) .filter(|one| !alike(one.by.as_deref(), now)) .map(|one| one.id) .collect() @@ -2310,6 +2315,7 @@ mod tests { Op::DocAdd { id, d: crate::event::DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -3253,6 +3259,7 @@ mod tests { Op::DocAdd { id: doc, d: crate::event::DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -3962,6 +3969,7 @@ mod tests { Op::DocAdd { id, d: crate::event::DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -4118,6 +4126,7 @@ mod tests { Op::DocAdd { id: one, d: crate::event::DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -4151,6 +4160,7 @@ mod tests { Op::DocAdd { id: one, d: crate::event::DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -4589,6 +4599,7 @@ mod tests { Op::DocAdd { id, d: crate::event::DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -4984,6 +4995,7 @@ mod compacting { Op::DocAdd { id, d: crate::event::DocAdd { + wrote: None, guest: false, made: None, by: None, diff --git a/crates/tisty-core/src/undo.rs b/crates/tisty-core/src/undo.rs index 28913819..de7623be 100644 --- a/crates/tisty-core/src/undo.rs +++ b/crates/tisty-core/src/undo.rs @@ -507,6 +507,7 @@ mod tests { Op::DocAdd { id, d: crate::event::DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -668,6 +669,7 @@ mod hanging { Op::DocAdd { id, d: crate::event::DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -768,6 +770,7 @@ mod hanging { Op::DocAdd { id: page, d: crate::event::DocAdd { + wrote: None, guest: false, made: None, by: None, diff --git a/crates/tisty-core/tests/pages.rs b/crates/tisty-core/tests/pages.rs index 5109c43a..eaba2a62 100644 --- a/crates/tisty-core/tests/pages.rs +++ b/crates/tisty-core/tests/pages.rs @@ -53,6 +53,7 @@ fn doc_add( .append(Op::DocAdd { id, d: DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -98,6 +99,7 @@ fn make( .append(Op::DocAdd { id, d: DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -455,6 +457,7 @@ fn a_document_cannot_become_a_page_of_itself_at_creation_or_by_moving() { .append(Op::DocAdd { id: itself, d: DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -551,6 +554,7 @@ fn undoing_a_creation_and_a_plain_move_restores_the_exact_state_before() { .append(Op::DocAdd { id: Ulid::generate(), d: DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -688,6 +692,7 @@ fn two_stores_that_diverge_over_a_page_and_a_deleted_parent_converge_regardless_ Op::DocAdd { id: parent, d: DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -710,6 +715,7 @@ fn two_stores_that_diverge_over_a_page_and_a_deleted_parent_converge_regardless_ Op::DocAdd { id: page, d: DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -763,6 +769,7 @@ fn a_page_written_with_a_timestamp_before_its_parent_is_kept_as_its_own_document Op::DocAdd { id: page, d: DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -782,6 +789,7 @@ fn a_page_written_with_a_timestamp_before_its_parent_is_kept_as_its_own_document Op::DocAdd { id: parent, d: DocAdd { + wrote: None, guest: false, made: None, by: None, diff --git a/crates/tisty-core/tests/pages_files.rs b/crates/tisty-core/tests/pages_files.rs index a7d71fd1..0dd8d4c7 100644 --- a/crates/tisty-core/tests/pages_files.rs +++ b/crates/tisty-core/tests/pages_files.rs @@ -39,6 +39,7 @@ fn add_doc( Op::DocAdd { id, d: DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -71,6 +72,7 @@ fn add_page( Op::DocAdd { id, d: DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -302,6 +304,7 @@ fn duplicating_a_document_with_a_page_reuses_the_same_attachment_file_without_co Op::DocAdd { id: twin, d: DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -330,6 +333,7 @@ fn duplicating_a_document_with_a_page_reuses_the_same_attachment_file_without_co Op::DocAdd { id: Ulid::generate(), d: DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -922,6 +926,7 @@ fn duplicating_a_book_rewrites_its_cover_to_name_its_own_pages_not_the_originals Op::DocAdd { id: twin, d: DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -946,6 +951,7 @@ fn duplicating_a_book_rewrites_its_cover_to_name_its_own_pages_not_the_originals Op::DocAdd { id: Ulid::generate(), d: DocAdd { + wrote: None, guest: false, made: None, by: None, diff --git a/crates/tisty-core/tests/parcelled.rs b/crates/tisty-core/tests/parcelled.rs index e5c63a52..91361b14 100644 --- a/crates/tisty-core/tests/parcelled.rs +++ b/crates/tisty-core/tests/parcelled.rs @@ -82,6 +82,7 @@ impl Room { self.tell(Op::DocAdd { id, d: DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -1105,22 +1106,121 @@ fn two_folders_that_spell_the_same_do_not_pour_into_one() { fn a_name_that_is_a_prefix_of_another_is_not_rewritten_in_the_middle() { let room = tmp(); let mut here = Room::new(room.path(), "mine"); - let (_, first) = here.doc("# Uno\n\nsoy el corto", None, None); - let (_, second) = here.doc("# Dos\n\nsoy el largo", None, None); + // The store hands out fixed-width names, so a prefix pair has to be written by hand. + for (file, body) in [ + ( + "mine-0001", + "# Corto + +soy el corto", + ), + ( + "mine-00011", + "# Largo + +soy el largo", + ), + ] { + std::fs::write( + here.data.join("docs").join(format!("{file}.md")), + docs::settled(body), + ) + .unwrap(); + let id = Ulid::generate(); + here.tell(Op::DocAdd { + id, + d: DocAdd { + file: file.into(), + order: order::last_of(here.state.docs.values().map(|one| one.order.as_str())), + said: Some(Said { + title: docs::titled(body), + bytes: None, + tags: Some(Vec::new()), + }), + ..Default::default() + }, + }); + } here.doc( - &format!("# Libro\n\n[a](tisty:doc/{first}) y [b](tisty:doc/{second})"), + "# Libro + +[a](tisty:doc/mine-0001) y [b](tisty:doc/mine-00011)", None, None, ); - let box_at = room.path().join("enlaces.tistyx"); + let box_at = room.path().join("prefijos.tistyx"); parcel::write(&here.data, &here.state, &[], &box_at, &Along::default()).unwrap(); let mut there = Room::new(room.path(), "theirs"); there.take_in(&box_at); let said = there.body(&there.titled("Libro").file); - let uno = there.titled("Uno").file.clone(); - let dos = there.titled("Dos").file.clone(); - assert!(said.contains(&format!("tisty:doc/{uno}")), "{said}"); - assert!(said.contains(&format!("tisty:doc/{dos}")), "{said}"); + let corto = there.titled("Corto").file.clone(); + let largo = there.titled("Largo").file.clone(); + assert!(said.contains(&format!("tisty:doc/{corto})")), "{said}"); + assert!( + !said.contains("mine-0001)"), + "the old name survived: {said}" + ); + assert!(said.contains(&format!("tisty:doc/{largo})")), "{said}"); +} + +#[test] +fn a_landing_in_flight_survives_a_sweep_from_the_same_process() { + let room = tmp(); + let data = room.path().join("data"); + std::fs::create_dir_all(&data).unwrap(); + let mine = data.join(format!(".landing-{}", std::process::id())); + let stale = data.join(".landing-999999"); + std::fs::create_dir_all(&mine).unwrap(); + std::fs::create_dir_all(&stale).unwrap(); + + parcel::swept(&data); + + assert!(mine.is_dir(), "a landing still in flight was swept away"); + assert!(!stale.exists(), "the leftovers of another one stayed"); +} + +#[test] +fn a_wide_tree_keeps_every_folder_it_was_kept_in() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + for n in 0..20 { + let up = here.folder(&format!("Raiz {n}"), None, "home"); + let down = here.folder(&format!("Rama {n}"), Some(up), "home"); + here.doc(&format!("# Doc {n}\n\ntexto"), Some(down), None); + } + + let out = room.path().join("plano"); + let sent = parcel::plainly(&here.data, &here.state, &[], &out, &Along::default()).unwrap(); + + assert_eq!(sent.docs, 20); + assert_eq!(sent.folders, 40, "some folders never made it into the tree"); + for n in 0..20 { + let at = out.join(format!("Raiz-{n}")).join(format!("Rama-{n}")); + assert!(at.is_dir(), "{at:?} was flattened into the root"); + } +} + +#[test] +fn an_attachment_named_with_an_anchor_still_lands() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + let shed = here.data.join("attachments").join("ab"); + std::fs::create_dir_all(&shed).unwrap(); + std::fs::write(shed.join("foto-91f2ab00.png"), b"a picture").unwrap(); + here.doc( + "# Con ancla\n\n![x]()", + None, + None, + ); + + let box_at = room.path().join("ancla.tistyx"); + let sent = parcel::write(&here.data, &here.state, &[], &box_at, &Along::default()).unwrap(); + assert_eq!(sent.files, 1); + + let mut there = Room::new(room.path(), "theirs"); + let landed = there.take_in(&box_at); + assert_eq!(landed.files, 1, "the picture was left behind: {landed:?}"); + assert_eq!(landed.missed, 0); } diff --git a/crates/tisty-core/tests/tagged.rs b/crates/tisty-core/tests/tagged.rs index 8fe42fef..0e9e77f4 100644 --- a/crates/tisty-core/tests/tagged.rs +++ b/crates/tisty-core/tests/tagged.rs @@ -43,6 +43,7 @@ fn the_tags_of_a_body_settle_after_one_note() { .append(Op::DocAdd { id: doc, d: tisty_core::event::DocAdd { + wrote: None, guest: false, made: None, by: None, diff --git a/crates/tisty-core/tests/telling.rs b/crates/tisty-core/tests/telling.rs index dff314ce..1d3a5182 100644 --- a/crates/tisty-core/tests/telling.rs +++ b/crates/tisty-core/tests/telling.rs @@ -295,6 +295,7 @@ fn a_note_reaches_the_document_it_speaks_of() { open.append(tisty_core::Op::DocAdd { id, d: tisty_core::event::DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -346,6 +347,7 @@ fn a_log_written_before_the_note_existed_still_opens() { open.append(tisty_core::Op::DocAdd { id, d: tisty_core::event::DocAdd { + wrote: None, guest: false, made: None, by: None, diff --git a/crates/tisty-sync/src/lib.rs b/crates/tisty-sync/src/lib.rs index 801d58eb..79f35201 100644 --- a/crates/tisty-sync/src/lib.rs +++ b/crates/tisty-sync/src/lib.rs @@ -1371,6 +1371,7 @@ mod tests { held.append(Op::DocAdd { id: Ulid::generate(), d: tisty_core::event::DocAdd { + wrote: None, guest: false, made: None, by: None, @@ -1598,6 +1599,7 @@ mod tests { Op::DocAdd { id: Ulid::generate(), d: tisty_core::event::DocAdd { + wrote: None, guest: false, made: None, by: None, From 41fe09b2b502f9d806ad4183baebe8357ae746f7 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 6 Sep 2026 23:12:48 -0300 Subject: [PATCH 6/8] fix: the hand that wrote it is sealed as it writes, not guessed afterwards A document said it was edited by whoever happens to be signing now, so one brought in from somebody else claimed your name without you ever opening it. The note a save writes carries the alias it was written under, and the card reads that. An alias this store signed with before reads as the one it signs with now, because it is the same hand; anybody else's stays theirs. The author still moves only when you re-sign what is yours, and only when the alias actually changed. Away from the window, a store written by a newer build now says to update rather than only naming the schema. --- app/src-tauri/src/lib.rs | 27 +++++--- crates/tisty-cli/src/cmd/demo.rs | 1 + crates/tisty-cli/src/mcp.rs | 2 +- crates/tisty-core/src/event/op.rs | 15 +++++ crates/tisty-core/src/lib.rs | 4 +- crates/tisty-core/src/model/folder.rs | 3 + crates/tisty-core/src/parcel.rs | 1 + crates/tisty-core/src/state.rs | 25 ++++++-- crates/tisty-core/src/tagging.rs | 2 + crates/tisty-core/src/tidy.rs | 1 + crates/tisty-core/tests/parcelled.rs | 89 +++++++++++++++++++++++++++ crates/tisty-core/tests/telling.rs | 4 ++ docs/ARCHITECTURE.md | 14 ++++- 13 files changed, 173 insertions(+), 15 deletions(-) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index d8f2145e..8c329485 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, @@ -2627,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, @@ -2697,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; }; @@ -3474,6 +3477,7 @@ fn guide( title: made.title.clone(), bytes: None, tags: Some(Vec::new()), + by: None, }), folder: Some(folder), page_of: None, @@ -3563,6 +3567,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; @@ -3570,6 +3575,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 }); @@ -3669,6 +3675,7 @@ fn doc_copy( title: made.title.clone(), bytes: None, tags: Some(kept.tags.clone()), + by: None, }), folder: kept.folder, page_of: kept.page_of, @@ -3713,6 +3720,7 @@ fn doc_copy( title: leaf.title, bytes: None, tags: Some(Vec::new()), + by: None, }), folder: kept.folder, page_of: Some(twin), @@ -4126,6 +4134,7 @@ fn doc_import( title: made.title.clone(), bytes: None, tags: Some(Vec::new()), + by: None, }), folder, page_of: None, @@ -4201,6 +4210,7 @@ fn doc_new( title: made.title.clone(), bytes: None, tags: Some(Vec::new()), + by: None, }), folder, page_of, @@ -5174,6 +5184,7 @@ fn settle_paper( title: made.title.clone(), bytes: None, tags: Some(Vec::new()), + by: None, }), page_of, }, @@ -7136,6 +7147,7 @@ mod ordering { title: tisty_core::docs::titled(super::GUIDE_ES), bytes: None, tags: Some(Vec::new()), + by: None, }), folder: None, page_of: None, @@ -7167,6 +7179,7 @@ mod ordering { title: "Mis notas".into(), bytes: None, tags: Some(Vec::new()), + by: None, }), folder: None, page_of: None, diff --git a/crates/tisty-cli/src/cmd/demo.rs b/crates/tisty-cli/src/cmd/demo.rs index b5ee57c6..636f1ef5 100644 --- a/crates/tisty-cli/src/cmd/demo.rs +++ b/crates/tisty-cli/src/cmd/demo.rs @@ -304,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/mcp.rs b/crates/tisty-cli/src/mcp.rs index ea2dbec5..d7340176 100644 --- a/crates/tisty-cli/src/mcp.rs +++ b/crates/tisty-cli/src/mcp.rs @@ -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, diff --git a/crates/tisty-core/src/event/op.rs b/crates/tisty-core/src/event/op.rs index 07e24832..1586f2c5 100644 --- a/crates/tisty-core/src/event/op.rs +++ b/crates/tisty-core/src/event/op.rs @@ -329,8 +329,15 @@ impl Op { } 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); @@ -568,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 { @@ -576,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 diff --git a/crates/tisty-core/src/lib.rs b/crates/tisty-core/src/lib.rs index eb2af1a0..64a13da1 100644 --- a/crates/tisty-core/src/lib.rs +++ b/crates/tisty-core/src/lib.rs @@ -83,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, diff --git a/crates/tisty-core/src/model/folder.rs b/crates/tisty-core/src/model/folder.rs index 8756c67f..dee3affb 100644 --- a/crates/tisty-core/src/model/folder.rs +++ b/crates/tisty-core/src/model/folder.rs @@ -58,6 +58,8 @@ pub struct Kept { 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")] @@ -104,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/parcel.rs b/crates/tisty-core/src/parcel.rs index cf8ff677..c9b40f6c 100644 --- a/crates/tisty-core/src/parcel.rs +++ b/crates/tisty-core/src/parcel.rs @@ -612,6 +612,7 @@ fn taken_in( title: made.title.clone(), bytes: Some(body.len() as u64), tags: Some(crate::tagging::tags_in(&body)), + by: None, }), folder, page_of: up, diff --git a/crates/tisty-core/src/state.rs b/crates/tisty-core/src/state.rs index 7d572b24..9729e045 100644 --- a/crates/tisty-core/src/state.rs +++ b/crates/tisty-core/src/state.rs @@ -312,6 +312,7 @@ impl State { wrote_by: Some(event.device.clone()), by: d.by.clone().or_else(|| here(d, &self.signed)), born_by: d.by.clone().or_else(|| here(d, &self.signed)), + edited_by: d.said.as_ref().and_then(|one| one.by.clone()), guest: d.guest, folder: match under { Some(one) => one.folder, @@ -339,6 +340,7 @@ impl State { } kept.wrote = Some(event.timestamp); kept.wrote_by = Some(event.device.clone()); + kept.edited_by = d.by.clone(); } } Op::DocMove { id, d } => { @@ -1224,11 +1226,25 @@ impl State { .collect() } - pub fn editor_of(&self, kept: &crate::model::Kept) -> Option<&str> { - match !alike(kept.by.as_deref(), self.signed.alias.as_deref()) && kept.wrote != kept.made { - true => self.signed.alias.as_deref(), - false => None, + /// A hand of mine reads as the alias I sign with now: the log keeps what it was, and + /// changing an alias rewrites nothing behind it. + pub fn editor_of<'a>(&'a self, kept: &'a crate::model::Kept) -> Option<&'a str> { + let hand = kept.edited_by.as_deref()?; + if alike(Some(hand), kept.by.as_deref()) { + return None; } + if !self.mine_to_write(hand) { + return Some(hand); + } + match kept.by.as_deref().is_some_and(|by| self.mine_to_write(by)) { + true => None, + false => Some(self.signed.alias.as_deref().unwrap_or(hand)), + } + } + + fn mine_to_write(&self, hand: &str) -> bool { + alike(Some(hand), self.signed.alias.as_deref()) + || self.signed_before.iter().any(|was| same_name(was, hand)) } pub fn docs_tagged(&self, tag: &Tag) -> impl Iterator { @@ -3269,6 +3285,7 @@ mod tests { title: "Acta".into(), bytes: None, tags: Some(vec![Tag::new("2").unwrap(), Tag::new("casa").unwrap()]), + by: None, }), ..Default::default() }, diff --git a/crates/tisty-core/src/tagging.rs b/crates/tisty-core/src/tagging.rs index 1a88d8bd..c3016155 100644 --- a/crates/tisty-core/src/tagging.rs +++ b/crates/tisty-core/src/tagging.rs @@ -249,12 +249,14 @@ mod tests { archived: false, locked: false, tags: vec![Tag::new("legal").unwrap()], + edited_by: None, }; let same = crate::event::Said { title: "Alquiler".into(), bytes: Some(31), tags: Some(vec![Tag::new("legal").unwrap()]), + by: None, }; assert!(!same.news_for(&kept)); diff --git a/crates/tisty-core/src/tidy.rs b/crates/tisty-core/src/tidy.rs index 10ca7ef3..91e26e39 100644 --- a/crates/tisty-core/src/tidy.rs +++ b/crates/tisty-core/src/tidy.rs @@ -337,6 +337,7 @@ mod tests { page_of: up, archived: false, locked: false, + edited_by: None, }, ); }; diff --git a/crates/tisty-core/tests/parcelled.rs b/crates/tisty-core/tests/parcelled.rs index 91361b14..b297bb63 100644 --- a/crates/tisty-core/tests/parcelled.rs +++ b/crates/tisty-core/tests/parcelled.rs @@ -92,6 +92,7 @@ impl Room { title: made.title.clone(), bytes: None, tags: Some(Vec::new()), + by: None, }), folder, page_of, @@ -811,12 +812,18 @@ fn taking_in_and_then_writing_says_who_wrote_last_without_taking_the_name_away() there.take_in(&box_at); let acta = there.titled("Acta").id; + assert_eq!( + there.state.editor_of(&there.state.docs[&acta]), + None, + "nobody has written it here yet" + ); there.tell(Op::DocSaid { id: acta, d: Said { title: "Acta".into(), bytes: Some(20), tags: Some(Vec::new()), + by: Some("rgdevment".into()), }, }); @@ -825,6 +832,87 @@ fn taking_in_and_then_writing_says_who_wrote_last_without_taking_the_name_away() assert_eq!(there.state.editor_of(acta), Some("rgdevment")); } +#[test] +fn a_hand_of_mine_reads_as_the_alias_i_sign_with_now() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + here.tell(Op::Signed { + d: tisty_core::event::Signature { + alias: Some("mario".into()), + ..Default::default() + }, + }); + here.doc( + "# Acta + +lo suyo", + None, + None, + ); + let acta = here.titled("Acta").id; + here.tell(Op::DocSaid { + id: acta, + d: Said { + title: "Acta".into(), + bytes: Some(20), + tags: Some(Vec::new()), + by: Some("mario".into()), + }, + }); + here.tell(Op::Signed { + d: tisty_core::event::Signature { + alias: Some("rgdevment".into()), + ..Default::default() + }, + }); + + let kept = &here.state.docs[&acta]; + assert_eq!( + here.state.editor_of(kept), + None, + "the author signs it as well, so there is nothing to add" + ); + assert_eq!( + here.state.docs[&acta].edited_by.as_deref(), + Some("mario"), + "the log keeps the hand it was written with" + ); +} + +#[test] +fn somebody_elses_hand_stays_theirs_however_i_sign() { + let room = tmp(); + let mut here = Room::new(room.path(), "mine"); + here.tell(Op::Signed { + d: tisty_core::event::Signature { + alias: Some("rgdevment".into()), + ..Default::default() + }, + }); + here.doc( + "# Acta + +lo mio", + None, + None, + ); + let acta = here.titled("Acta").id; + here.tell(Op::DocSaid { + id: acta, + d: Said { + title: "Acta".into(), + bytes: Some(20), + tags: Some(Vec::new()), + by: Some("fulanito".into()), + }, + }); + + assert_eq!( + here.state.editor_of(&here.state.docs[&acta]), + Some("fulanito") + ); +} + #[test] fn the_aliases_this_store_signed_with_are_kept_apart_from_the_ones_that_arrived() { let room = tmp(); @@ -1136,6 +1224,7 @@ soy el largo", title: docs::titled(body), bytes: None, tags: Some(Vec::new()), + by: None, }), ..Default::default() }, diff --git a/crates/tisty-core/tests/telling.rs b/crates/tisty-core/tests/telling.rs index 1d3a5182..e46807ba 100644 --- a/crates/tisty-core/tests/telling.rs +++ b/crates/tisty-core/tests/telling.rs @@ -263,6 +263,7 @@ fn a_note_of_what_a_document_said_is_one_an_older_build_can_walk_past() { title: "Lo que dice".into(), bytes: Some(12), tags: Some(Vec::new()), + by: None, }, }; @@ -305,6 +306,7 @@ fn a_note_reaches_the_document_it_speaks_of() { title: "Como nacio".into(), bytes: None, tags: Some(Vec::new()), + by: None, }), folder: None, page_of: None, @@ -325,6 +327,7 @@ fn a_note_reaches_the_document_it_speaks_of() { title: "Como se llama ahora".into(), bytes: Some(40), tags: Some(Vec::new()), + by: None, }, }) .unwrap(); @@ -382,6 +385,7 @@ fn the_note_goes_out_marked_so_an_older_build_skips_it() { title: "Algo".into(), bytes: None, tags: Some(Vec::new()), + by: None, }, }) .unwrap(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eb172e1f..402a5dda 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -55,7 +55,7 @@ name it was written under in its own `doc.add`. `doc.signed`, which re-signs a document already written, is **not** marked — dropping it would show the old name on one machine and the new one on the next. -Three payload fields carry more than their name says: +Some payload fields carry more than their name says: | Field | On | Meaning | |---|---|---| @@ -63,6 +63,7 @@ Three payload fields carry more than their name says: | `source` | `task.add` | what the task was written from, so the same thing is not filed twice | | `filled` | `task.done` | closed in bulk by the backfill, so its stamp is the hour of the marking rather than its own | | `tags` | `doc.said` | the tags read out of the body. Absent is not «none»: it is a build that did not read them, and treating the two alike would have an older machine wipe the tags of every document it saved | +| `by` | `doc.said` | the alias the body was saved under, sealed at the writing rather than worked out afterwards from whoever happens to be signing now. Absent is a hand that did not sign, not the reader's own | `active.tisty` is sealed as `NNNNNN.tisty` every 5.000 events. Sealed segments are numbered from one without gaps. @@ -1148,10 +1149,19 @@ of everything, because nothing in it says «you already have this». The store that receives it decides what is a guest by the identity of the store that sent it, written in the manifest, and never by the name inside: two people -who happen to share an alias do not inherit each other's writing, and a parcel +who happen to share an alias do not inherit each other's writing, and what comes from somebody who never signed lands without an author rather than under the name of whoever opened it. +Who *edited* it does not travel at all. The author is the document's, and it is +the same wherever the document goes; the hand that last wrote is this store's +own reading, sealed in `doc.said` as it was written. Changing an alias rewrites +neither one: the log keeps what it kept, «signed before as» keeps the name the +document was born under, and re-signing what is mine is a deliberate act with +its own event. What the reading does is resolve — an alias this store signed +with before reads as the alias it signs with now, because it is the same hand, +while somebody else's stays theirs however this machine signs today. + **Export to PDF** is the one that leaves Markdown behind, and Tisty composes it rather than asking the system to print. That is a deliberate cost. Printing hands the page to the operating system, and the operating system decides: on macOS the From 92daad731fac276ee148a34b16a856f6f8637e2e Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 6 Sep 2026 23:17:07 -0300 Subject: [PATCH 7/8] fix: the test reads down the page, the way the formatter wants it --- app/src/tests/keeping.test.tsx | 40 ++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/app/src/tests/keeping.test.tsx b/app/src/tests/keeping.test.tsx index 3d3cedbd..c00c0020 100644 --- a/app/src/tests/keeping.test.tsx +++ b/app/src/tests/keeping.test.tsx @@ -1602,12 +1602,28 @@ describe("the maintenance panel", () => { it("reads its settings again once the welcome has been through", async () => { const { rerender } = render( - {}} onUnpack={() => {}} greeted={0} onGreet={() => {}} onChanged={() => {}} onDoc={() => {}} />, + {}} + onUnpack={() => {}} + greeted={0} + onGreet={() => {}} + onChanged={() => {}} + onDoc={() => {}} + />, ); await data(); const before = sent("settings").length; - rerender( {}} onUnpack={() => {}} greeted={1} onGreet={() => {}} onChanged={() => {}} onDoc={() => {}} />); + rerender( + {}} + onUnpack={() => {}} + greeted={1} + onGreet={() => {}} + onChanged={() => {}} + onDoc={() => {}} + />, + ); await waitFor(() => expect(sent("settings").length).toBe(before + 1)); }); @@ -1632,7 +1648,15 @@ describe("the maintenance panel", () => { it("offers the welcome again, without touching what is written", async () => { const greet = vi.fn(); - render( {}} onUnpack={() => {}} onGreet={greet} onChanged={() => {}} onDoc={() => {}} />); + render( + {}} + onUnpack={() => {}} + onGreet={greet} + onChanged={() => {}} + onDoc={() => {}} + />, + ); await ready(); await userEvent.click(await screen.findByRole("button", { name: /show it again/i })); @@ -1643,7 +1667,15 @@ describe("the maintenance panel", () => { it("opens the guide instead of only saying where it went", async () => { const opened = vi.fn(); - render( {}} onUnpack={() => {}} onGreet={() => {}} onChanged={() => {}} onDoc={opened} />); + render( + {}} + onUnpack={() => {}} + onGreet={() => {}} + onChanged={() => {}} + onDoc={opened} + />, + ); await ready(); await userEvent.click(await screen.findByRole("button", { name: /open the guide/i })); From f4a64539e8c87e4c007f6f6f5dd9f8d1957c06b7 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 6 Sep 2026 23:32:35 -0300 Subject: [PATCH 8/8] fix: the first name stays first, and what nobody signed is there to be claimed --- app/src-tauri/src/lib.rs | 19 ++++++++++++++-- crates/tisty-core/src/state.rs | 14 +++++++++--- crates/tisty-core/tests/parcelled.rs | 34 ++++++++++++++++++++++------ 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 8c329485..d34eb728 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3226,7 +3226,18 @@ fn signing(state: &tisty_core::State) -> Option { fn as_signed(session: &Session) -> Signed { Signed { alias: session.state.signed.alias.clone(), - before: session.state.signed_before.iter().rev().cloned().collect(), + 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, @@ -3274,7 +3285,11 @@ fn sign(session: tauri::State<'_, Mutex>, alias: Option) -> Ans } let mut session = held(&session); - if said == session.state.signed.alias { + 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(); diff --git a/crates/tisty-core/src/state.rs b/crates/tisty-core/src/state.rs index 9729e045..ca724b2d 100644 --- a/crates/tisty-core/src/state.rs +++ b/crates/tisty-core/src/state.rs @@ -451,8 +451,14 @@ impl State { name: said(&d.name), email: said(&d.email), }; - if let Some(one) = &self.signed.alias { - self.signed_before.retain(|was| !same_name(was, one)); + // Kept in the order they were signed, and never thinned: going back to a + // name years later must not move it ahead of whoever was first. + if let Some(one) = &self.signed.alias + && !self + .signed_before + .last() + .is_some_and(|was| same_name(was, one)) + { self.signed_before.push(one.clone()); } } @@ -1220,7 +1226,9 @@ impl State { let now = self.signed.alias.as_deref(); self.docs .values() - .filter(|one| !self.written_shut(one.id) && !one.guest) + .filter(|one| !self.written_shut(one.id)) + // Somebody else's writing is theirs; what nobody ever signed is there to be claimed. + .filter(|one| !one.guest || one.by.is_none()) .filter(|one| !alike(one.by.as_deref(), now)) .map(|one| one.id) .collect() diff --git a/crates/tisty-core/tests/parcelled.rs b/crates/tisty-core/tests/parcelled.rs index b297bb63..a08d3611 100644 --- a/crates/tisty-core/tests/parcelled.rs +++ b/crates/tisty-core/tests/parcelled.rs @@ -938,7 +938,11 @@ fn the_aliases_this_store_signed_with_are_kept_apart_from_the_ones_that_arrived( } there.take_in(&box_at); - assert_eq!(there.state.signed_before, ["mario", "rgdevment"]); + assert_eq!( + there.state.signed_before, + ["rgdevment", "mario", "rgdevment"], + "going back to a name it had signed with before was not written down" + ); assert_eq!(there.state.signed.alias.as_deref(), Some("rgdevment")); assert_eq!( there.state.author_of(there.titled("Suyo")), @@ -1046,7 +1050,7 @@ fn coming_home_under_the_same_name_leaves_no_mark_however_it_was_typed() { ); assert_eq!( here.state.signed_before, - ["RGDEVMENT"], + ["rgdevment"], "the history kept the same name twice" ); for one in here.state.docs.values() { @@ -1139,15 +1143,31 @@ fn a_parcel_from_a_store_that_never_signed_is_not_yours_to_claim() { }); there.take_in(&box_at); - let acta = there.titled("Acta"); + let acta = there.titled("Acta").id; assert_eq!( - there.state.author_of(acta), + there.state.author_of(&there.state.docs[&acta]), None, "an unsigned document was credited to whoever took it in" ); - assert!( - there.state.mine_to_sign().is_empty(), - "it offered to sign somebody else's writing" + assert_eq!( + there.state.mine_to_sign(), + [acta], + "writing nobody ever signed was not there to be claimed" + ); + + there.tell(Op::DocSigned { + id: acta, + d: "rgdevment".into(), + }); + assert_eq!( + there.state.author_of(&there.state.docs[&acta]), + Some("rgdevment"), + "the first to sign it did not become its author" + ); + assert_eq!( + there.state.born_of(&there.state.docs[&acta]), + None, + "it claimed a name it never had before" ); }