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
)}
- {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"),
+ )
+ .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) {
/>
+
+
+
{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" && (