mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-12 09:47:04 +01:00
tree: several nodes picked up and moved at once
m now adds the row under the cursor to a set of marks and steps on, so
mmm picks up three; M takes the whole folder, Esc or :unmark puts it all
back. Marks stay while the list walks, so they can be gathered from
several folders. p, :mv and Shift+digit move the whole set.
With a pointer, Ctrl- or Cmd-click marks a row and Shift-click every row
from the cursor to it. Rows drag onto a folder row, a crumb or a space
pill; a marked row carries every marked node with it.
POST /api/nodes/move takes { ids, parent_id } and moves them all or
none: every node is checked before any moves, sealing into a sealed
folder happens before the one transaction that moves them, and two of a
name get slugs of their own. The single move goes the same way.
This commit is contained in:
1 parent
b19fb21fc4
commit
6bf88e7ffd
15 files changed
+407
-111
No files matched your search
@@ -63,6 +63,7 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
|
||||
.route("/nodes/{id}", get(nodes::get))
|
||||
.route("/nodes/{id}", put(nodes::update))
|
||||
.route("/nodes/{id}", delete(nodes::remove))
|
||||
.route("/nodes/move", post(nodes::move_many))
|
||||
.route("/nodes/{id}/move", post(nodes::move_node))
|
||||
.route("/import", post(import::import_root).layer(uploads))
|
||||
.route("/nodes/{id}/import", post(import::import).layer(uploads))
|
||||
|
||||
+129
-53
@@ -1,4 +1,4 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
@@ -171,6 +171,23 @@ pub struct MoveInput {
|
||||
pub position: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MoveManyInput {
|
||||
pub ids: Vec<i64>,
|
||||
pub parent_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
pub struct Moved {
|
||||
pub moved: usize,
|
||||
/// Already in the folder they were sent to.
|
||||
pub unchanged: usize,
|
||||
}
|
||||
|
||||
/// How many nodes one move takes: a folder's worth picked up by hand, with
|
||||
/// room to spare, and still one short transaction.
|
||||
const MOVE_AT_ONCE: usize = 1000;
|
||||
|
||||
/// How much of a body the list query reads. Comfortably more than an excerpt
|
||||
/// needs, and a rounding error next to documents that run to thousands of words.
|
||||
const HEAD_CHARS: usize = 1024;
|
||||
@@ -763,68 +780,127 @@ pub async fn move_node(
|
||||
Path(id): Path<i64>,
|
||||
Json(input): Json<MoveInput>,
|
||||
) -> AppResult<Json<Node>> {
|
||||
if input.parent_id == id {
|
||||
return Err(AppError::BadRequest("a node cannot contain itself".into()));
|
||||
move_nodes(&state, &[id], input.parent_id, input.position).await?;
|
||||
load(&state, id).await.map(Json)
|
||||
}
|
||||
|
||||
/// Move several nodes into one folder: all of them, or — when one of them
|
||||
/// cannot go there — none.
|
||||
pub async fn move_many(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<MoveManyInput>,
|
||||
) -> AppResult<Json<Moved>> {
|
||||
let mut seen = HashSet::new();
|
||||
let ids: Vec<i64> = input
|
||||
.ids
|
||||
.into_iter()
|
||||
.filter(|id| seen.insert(*id))
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
return Err(AppError::BadRequest("there is nothing to move".into()));
|
||||
}
|
||||
// Into a sealed folder, the files go sealed or not at all — and only
|
||||
// into a place it may go at all, which the transaction checks again.
|
||||
if ids.len() > MOVE_AT_ONCE {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{MOVE_AT_ONCE} nodes at most move at once"
|
||||
)));
|
||||
}
|
||||
move_nodes(&state, &ids, input.parent_id, None)
|
||||
.await
|
||||
.map(Json)
|
||||
}
|
||||
|
||||
/// Put `ids` under `parent`, in that order. Every node is checked before any
|
||||
/// moves; into a sealed folder they go sealed, or nothing goes.
|
||||
pub async fn move_nodes(
|
||||
state: &AppState,
|
||||
ids: &[i64],
|
||||
parent: i64,
|
||||
position: Option<i64>,
|
||||
) -> AppResult<Moved> {
|
||||
// Checked before sealing, so that nothing is sealed for a move that is
|
||||
// refused anyway, and again in the transaction that moves them.
|
||||
{
|
||||
let mut conn = state.db.acquire().await?;
|
||||
container(&mut conn, input.parent_id).await?;
|
||||
if descendants(&mut conn, id).await?.contains(&input.parent_id) {
|
||||
return Err(AppError::BadRequest(
|
||||
"a folder cannot be moved into itself".into(),
|
||||
));
|
||||
}
|
||||
movable(&mut conn, ids, parent).await?;
|
||||
}
|
||||
for &id in ids {
|
||||
crate::routes::seal::enter(state, id, parent).await?;
|
||||
}
|
||||
crate::routes::seal::enter(&state, id, input.parent_id).await?;
|
||||
|
||||
let mut tx = crate::db::begin_write(&state.db).await?;
|
||||
|
||||
let row = sqlx::query("SELECT kind, name FROM nodes WHERE id = ?1")
|
||||
let nodes = movable(&mut tx, ids, parent).await?;
|
||||
let mut done = Moved {
|
||||
moved: 0,
|
||||
unchanged: 0,
|
||||
};
|
||||
for (id, name, from) in nodes {
|
||||
if from == Some(parent) && position.is_none() {
|
||||
done.unchanged += 1;
|
||||
continue;
|
||||
}
|
||||
// The slug has to be free where it lands, not where it came from —
|
||||
// and it sees the ones moved in before it, so two `notes` stay apart.
|
||||
let slug = unique_slug(&mut tx, Some(parent), &slugify(&name), Some(id)).await?;
|
||||
sqlx::query(
|
||||
"UPDATE nodes
|
||||
SET parent_id = ?1, slug = ?2, position = COALESCE(?3, position), updated_at = ?4
|
||||
WHERE id = ?5",
|
||||
)
|
||||
.bind(parent)
|
||||
.bind(&slug)
|
||||
.bind(position)
|
||||
.bind(crate::now())
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
let kind: String = row.get("kind");
|
||||
let name: String = row.get("name");
|
||||
// A space is a root: under a folder it would drop out of the switcher
|
||||
// while its board and its cards carried on as if nothing had happened.
|
||||
if kind == SPACE {
|
||||
return Err(AppError::BadRequest(
|
||||
"a space cannot be moved into anything".into(),
|
||||
));
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
done.moved += 1;
|
||||
}
|
||||
|
||||
container(&mut tx, input.parent_id).await?;
|
||||
|
||||
// Moving a folder into its own subtree would cut that subtree loose from
|
||||
// every root, where nothing could reach it again.
|
||||
if descendants(&mut tx, id).await?.contains(&input.parent_id) {
|
||||
return Err(AppError::BadRequest(
|
||||
"a folder cannot be moved into itself".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// The slug has to be free where it lands, not where it came from.
|
||||
let slug = unique_slug(&mut tx, Some(input.parent_id), &slugify(&name), Some(id)).await?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE nodes
|
||||
SET parent_id = ?1, slug = ?2, position = COALESCE(?3, position), updated_at = ?4
|
||||
WHERE id = ?5",
|
||||
)
|
||||
.bind(input.parent_id)
|
||||
.bind(&slug)
|
||||
.bind(input.position)
|
||||
.bind(crate::now())
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
state.backup.signal();
|
||||
load(&state, id).await.map(Json)
|
||||
if done.moved > 0 {
|
||||
state.backup.signal();
|
||||
}
|
||||
Ok(done)
|
||||
}
|
||||
|
||||
/// Refuse the move unless every one of `ids` may go into `parent`; the id,
|
||||
/// name and present parent of each when they all may.
|
||||
async fn movable(
|
||||
db: &mut SqliteConnection,
|
||||
ids: &[i64],
|
||||
parent: i64,
|
||||
) -> AppResult<Vec<(i64, String, Option<i64>)>> {
|
||||
container(&mut *db, parent).await?;
|
||||
let mut nodes = Vec::with_capacity(ids.len());
|
||||
for &id in ids {
|
||||
if id == parent {
|
||||
return Err(AppError::BadRequest("a node cannot contain itself".into()));
|
||||
}
|
||||
let row = sqlx::query("SELECT kind, name, parent_id FROM nodes WHERE id = ?1")
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
let kind: String = row.get("kind");
|
||||
let name: String = row.get("name");
|
||||
// A space is a root: under a folder it would drop out of the switcher
|
||||
// while its board and its cards carried on as if nothing had happened.
|
||||
if kind == SPACE {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{name} is a space, and a space cannot be moved into anything"
|
||||
)));
|
||||
}
|
||||
// Moving a folder into its own subtree would cut that subtree loose
|
||||
// from every root, where nothing could reach it again.
|
||||
if descendants(&mut *db, id).await?.contains(&parent) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{name} cannot be moved into itself"
|
||||
)));
|
||||
}
|
||||
nodes.push((id, name, row.get("parent_id")));
|
||||
}
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
/// Refuse anything but a space or a folder as the place a node goes into.
|
||||
|
||||
@@ -508,6 +508,71 @@ mod tests {
|
||||
enter(&w.state, doc, w.folder).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn several_move_together_or_not_at_all() {
|
||||
use crate::routes::nodes::{move_nodes, Moved};
|
||||
|
||||
let w = world().await;
|
||||
let from = node(&w.state, Some(w.space), "folder", "from").await;
|
||||
let a = plain_file(&w.state, from, "notes.txt").await.unwrap();
|
||||
let b = node(&w.state, Some(w.space), "folder", "inner").await;
|
||||
let c = plain_file(&w.state, b, "notes.txt").await.unwrap();
|
||||
let slug = |id: i64| {
|
||||
sqlx::query_scalar::<_, String>("SELECT slug FROM nodes WHERE id = ?1")
|
||||
.bind(id)
|
||||
.fetch_one(&w.state.db)
|
||||
};
|
||||
let parent = |id: i64| {
|
||||
sqlx::query_scalar::<_, i64>("SELECT parent_id FROM nodes WHERE id = ?1")
|
||||
.bind(id)
|
||||
.fetch_one(&w.state.db)
|
||||
};
|
||||
|
||||
// A folder among them that would go into itself stops all of them.
|
||||
let refused = move_nodes(&w.state, &[a, b], b, None).await.unwrap_err();
|
||||
assert!(matches!(&refused, AppError::BadRequest(m) if m.contains("itself")));
|
||||
assert_eq!(parent(a).await.unwrap(), from);
|
||||
// So does a space.
|
||||
assert!(move_nodes(&w.state, &[a, w.space], w.folder, None)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(parent(a).await.unwrap(), from);
|
||||
|
||||
// Two of a name land side by side, and what is already there stays.
|
||||
let done = move_nodes(&w.state, &[a, c, from], w.space, None).await;
|
||||
assert_eq!(
|
||||
done.unwrap(),
|
||||
Moved {
|
||||
moved: 2,
|
||||
unchanged: 1
|
||||
}
|
||||
);
|
||||
assert_eq!(parent(c).await.unwrap(), w.space);
|
||||
assert_ne!(slug(a).await.unwrap(), slug(c).await.unwrap());
|
||||
|
||||
// Into a sealed folder with the vault shut, none goes.
|
||||
seal_node(&w.state, w.folder).await.unwrap();
|
||||
w.state.vault.lock();
|
||||
assert!(matches!(
|
||||
move_nodes(&w.state, &[a, c], w.folder, None).await,
|
||||
Err(AppError::Locked)
|
||||
));
|
||||
assert_eq!(parent(a).await.unwrap(), w.space);
|
||||
|
||||
// With it open, they all go, and sealed.
|
||||
w.state.vault.open_with(Zeroizing::new([7u8; 32]));
|
||||
let done = move_nodes(&w.state, &[a, c, b], w.folder, None).await;
|
||||
assert_eq!(
|
||||
done.unwrap(),
|
||||
Moved {
|
||||
moved: 3,
|
||||
unchanged: 0
|
||||
}
|
||||
);
|
||||
assert!(encrypted(&w.state, a).await && encrypted(&w.state, c).await);
|
||||
assert!(sealed(&w.state, b).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_the_sealed_folder_itself_opens() {
|
||||
let w = world().await;
|
||||
|
||||
Reference in new issue
Block a user