From 79cc199d11e2c8b8152336a41db51b36da53614b Mon Sep 17 00:00:00 2001 From: mow Date: Sun, 15 Mar 2026 12:25:39 +0100 Subject: [PATCH] refactor: workspace/session/item ... ja ja, i know but now it is nailed. --- src/active_state.rs | 165 +++++++++++++++++++ src/patch.rs | 338 +++++++++++++++++++++++++++++++++++++++ src/workspace/manager.rs | 174 ++++++++++++++++++++ src/workspace/mod.rs | 7 + src/workspace/model.rs | 100 ++++++++++++ test.json | 94 +++++++++++ 6 files changed, 878 insertions(+) create mode 100644 src/active_state.rs create mode 100644 src/patch.rs create mode 100644 src/workspace/manager.rs create mode 100644 src/workspace/mod.rs create mode 100644 src/workspace/model.rs create mode 100644 test.json diff --git a/src/active_state.rs b/src/active_state.rs new file mode 100644 index 0000000..6525747 --- /dev/null +++ b/src/active_state.rs @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/active_state.rs +// +// Pure navigation state: tracks which session and item are currently active. + +use anyhow::{Context, Result}; +use uuid::Uuid; + +use crate::workspace::model::{Item, Session, Workspace}; + +/// Tracks the active session and item selection. +/// +/// `ActiveState` does not own or store a `Workspace`. All methods that need +/// workspace data receive it as a parameter. This keeps navigation state +/// cleanly separated from domain data. +#[derive(Debug, Clone)] +pub struct ActiveState { + active_session: Option, + active_item: Option, +} + +impl Default for ActiveState { + fn default() -> Self { + Self::new() + } +} + +impl ActiveState { + #[must_use] + pub fn new() -> Self { + Self { + active_session: None, + active_item: None, + } + } + + /// Clear all navigation state. + pub fn reset(&mut self) { + self.active_session = None; + self.active_item = None; + } + + pub fn select_session(&mut self, workspace: &Workspace, name: &str) -> Result<()> { + let session = workspace.session_by_name(name)?; + if self.active_session == Some(session.id) { + return Ok(()); + } + self.active_session = Some(session.id); + self.active_item = if session.items.is_empty() { + None + } else { + Some(0) + }; + Ok(()) + } + + pub fn select_session_by_id(&mut self, workspace: &Workspace, id: Uuid) -> Result<()> { + if self.active_session == Some(id) { + return Ok(()); + } + let session = workspace.session(id)?; + self.active_session = Some(session.id); + self.active_item = if session.items.is_empty() { + None + } else { + Some(0) + }; + Ok(()) + } + + pub fn clear_session(&mut self) { + self.active_session = None; + self.active_item = None; + } + + pub fn select_item(&mut self, workspace: &Workspace, index: usize) -> Result<()> { + let session = self.require_active_session(workspace)?; + if index >= session.items.len() { + anyhow::bail!( + "Item index {index} out of range (session {:?} has {} items)", + session.name, + session.items.len() + ); + } + self.active_item = Some(index); + Ok(()) + } + + pub fn next_item(&mut self, workspace: &Workspace) { + let count = self.item_count(workspace); + if let Some(idx) = self.active_item { + if idx + 1 < count { + self.active_item = Some(idx + 1); + } + } + } + + pub fn previous_item(&mut self) { + if let Some(idx) = self.active_item { + if idx > 0 { + self.active_item = Some(idx - 1); + } + } + } + + /// Adjust the item cursor after an item was removed from the active session. + pub fn adjust_after_remove(&mut self, workspace: &Workspace) { + let count = self.item_count(workspace); + self.active_item = if count == 0 { + None + } else { + Some(self.active_item.unwrap_or(0).min(count - 1)) + }; + } + + /// Point the item cursor to the last item in the active session. + pub fn select_last_item(&mut self, workspace: &Workspace) { + let count = self.item_count(workspace); + self.active_item = if count == 0 { None } else { Some(count - 1) }; + } + + // -- Read-only accessors -- + + #[must_use] + pub fn active_session_id(&self) -> Option { + self.active_session + } + + #[must_use] + pub fn active_item_index(&self) -> Option { + self.active_item + } + + /// Returns the active session from the workspace. + #[must_use] + pub fn active_session<'a>(&self, workspace: &'a Workspace) -> Option<&'a Session> { + let id = self.active_session?; + workspace.session(id).ok() + } + + /// Returns the active item from the active session. + #[must_use] + pub fn active_item<'a>(&self, workspace: &'a Workspace) -> Option<&'a Item> { + let session = self.active_session(workspace)?; + let idx = self.active_item?; + session.items.get(idx) + } + + /// Returns the UUID of the active item. + #[must_use] + pub fn active_item_id(&self, workspace: &Workspace) -> Option { + self.active_item(workspace).map(|i| i.id) + } + + // Private helpers + + fn require_active_session<'a>(&self, workspace: &'a Workspace) -> Result<&'a Session> { + let id = self.active_session.context("No active session")?; + workspace.session(id) + } + + fn item_count(&self, workspace: &Workspace) -> usize { + self.active_session(workspace).map_or(0, |s| s.items.len()) + } +} diff --git a/src/patch.rs b/src/patch.rs new file mode 100644 index 0000000..2e44fcd --- /dev/null +++ b/src/patch.rs @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/patch.rs +// +// Patch file executor for scripted workspace operations and testing. + +use crate::active_state::ActiveState; +use crate::workspace::model::{Item, ItemKind, Session, SessionType, Workspace}; +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +/// A single step in a patch file. Each variant maps directly to a JSON key. +#[derive(Debug, Deserialize)] +pub enum PatchStep { + NewWorkspace(String), + LoadWorkspace(String), + SaveWorkspace(String), + + CreateSession { + name: String, + session_type: String, + }, + DeleteSession(String), + CopySession(String), + RenameSession { + session: String, + name: String, + }, + + AddItem { + session: String, + name: String, + kind: String, + path: String, + }, + RemoveItem { + session: String, + item: String, + }, + RenameItem { + session: String, + item: String, + name: String, + }, + CopyItem { + from: String, + item: String, + to: String, + }, + MoveItem { + from: String, + item: String, + to: String, + }, + + SelectSession(String), + SelectItem(usize), + NextItem, + PreviousItem, + MoveItemUp, + MoveItemDown, + MoveItemToIndex(usize), + + ViewWorkspace, + ViewSession(String), +} + +pub fn load(path: &Path) -> Result> { + let content = std::fs::read_to_string(path).context("Failed to read patch file")?; + serde_json::from_str(&content).context("Failed to parse patch file") +} + +pub fn run(path: &Path) { + let steps = match load(path) { + Ok(s) => s, + Err(e) => { + eprintln!("patch: {e}"); + return; + } + }; + + let total = steps.len(); + let width = total.to_string().len(); + + println!("patch: '{}' ({total} steps)\n", path.display()); + + let mut ws = Workspace::new("untitled"); + let mut active = ActiveState::new(); + + for (i, step) in steps.into_iter().enumerate() { + let n = i + 1; + print!("[{n:>width$}/{total}] "); + if let Err(e) = execute(step, &mut ws, &mut active) { + println!("[ERR] {e}"); + } + } + + println!("\npatch: done."); +} + +fn execute(step: PatchStep, ws: &mut Workspace, active: &mut ActiveState) -> Result<()> { + match step { + PatchStep::NewWorkspace(name) => { + println!("New Workspace {name:?}"); + *ws = Workspace::new(&name); + active.reset(); + } + PatchStep::LoadWorkspace(path) => { + *ws = Workspace::load(&path)?; + active.reset(); + println!("Load Workspace {path:?} ({} sessions)", ws.sessions.len()); + } + PatchStep::SaveWorkspace(path) => { + ws.save(&path)?; + println!("Save Workspace {path:?}"); + } + + PatchStep::CreateSession { name, session_type } => { + let st = parse_session_type(&session_type)?; + let session = Session::new(&name, st); + let id = session.id; + ws.add_session(session); + active.select_session_by_id(ws, id)?; + println!("Create Session {name:?}"); + } + PatchStep::DeleteSession(name) => { + let id = ws.session_by_name(&name)?.id; + ws.remove_session(id)?; + if active.active_session_id() == Some(id) { + active.clear_session(); + } + println!("Delete Session {name:?}"); + } + PatchStep::CopySession(name) => { + let id = ws.session_by_name(&name)?.id; + let new_id = ws.copy_session(id)?; + let new_name = ws.session(new_id)?.name.clone(); + active.select_session_by_id(ws, new_id)?; + println!("Copy Session {name:?} -> {new_name:?}"); + } + PatchStep::RenameSession { session, name } => { + let id = ws.session_by_name(&session)?.id; + ws.rename_session(id, &name)?; + println!("Rename Session {session:?} -> {name:?}"); + } + + PatchStep::AddItem { + session, + name, + kind, + path, + } => { + let sid = ws.session_by_name(&session)?.id; + let item = Item::new(&name, parse_item_kind(&kind, &path)?); + ws.add_item(sid, item)?; + if active.active_session_id() == Some(sid) { + active.select_last_item(ws); + } + println!("Add Item {name:?} to {session:?}"); + } + PatchStep::RemoveItem { session, item } => { + let sid = ws.session_by_name(&session)?.id; + let iid = ws.item_by_name(sid, &item)?.id; + ws.remove_item(sid, iid)?; + if active.active_session_id() == Some(sid) { + active.adjust_after_remove(ws); + } + println!("Remove Item {item:?} from {session:?}"); + } + PatchStep::RenameItem { + session, + item, + name, + } => { + let sid = ws.session_by_name(&session)?.id; + let iid = ws.item_by_name(sid, &item)?.id; + ws.rename_item(sid, iid, &name)?; + println!("Rename Item {item:?} -> {name:?}"); + } + PatchStep::CopyItem { from, item, to } => { + let from_id = ws.session_by_name(&from)?.id; + let iid = ws.item_by_name(from_id, &item)?.id; + let to_id = ws.session_by_name(&to)?.id; + ws.copy_item(from_id, iid, to_id, usize::MAX)?; + println!("Copy Item {item:?}: {from:?} -> {to:?}"); + } + PatchStep::MoveItem { from, item, to } => { + let from_id = ws.session_by_name(&from)?.id; + let iid = ws.item_by_name(from_id, &item)?.id; + let to_id = ws.session_by_name(&to)?.id; + ws.move_item(from_id, iid, to_id, usize::MAX)?; + if active.active_session_id() == Some(from_id) { + active.adjust_after_remove(ws); + } + println!("Move Item {item:?}: {from:?} -> {to:?}"); + } + + PatchStep::SelectSession(name) => { + active.select_session(ws, &name)?; + println!("Select Session {name:?}"); + print_context(ws, active); + } + PatchStep::SelectItem(index) => { + active.select_item(ws, index)?; + println!("Select Item [{index}]"); + print_context(ws, active); + } + PatchStep::NextItem => { + active.next_item(ws); + println!("Next Item"); + print_context(ws, active); + } + PatchStep::PreviousItem => { + active.previous_item(); + println!("Previous Item"); + print_context(ws, active); + } + PatchStep::MoveItemUp => { + let sid = active.active_session_id().context("No active session")?; + let idx = active.active_item_index().context("No active item")?; + if idx == 0 { + bail!("Already at top") + } + let iid = active.active_item_id(ws).context("No active item")?; + ws.move_item(sid, iid, sid, idx - 1)?; + active.select_item(ws, idx - 1)?; + println!("Move Item Up"); + print_context(ws, active); + } + PatchStep::MoveItemDown => { + let sid = active.active_session_id().context("No active session")?; + let idx = active.active_item_index().context("No active item")?; + let count = active.active_session(ws).map_or(0, |s| s.items.len()); + if idx + 1 >= count { + bail!("Already at bottom") + } + let iid = active.active_item_id(ws).context("No active item")?; + ws.move_item(sid, iid, sid, idx + 1)?; + active.select_item(ws, idx + 1)?; + println!("Move Item Down"); + print_context(ws, active); + } + PatchStep::MoveItemToIndex(target) => { + let sid = active.active_session_id().context("No active session")?; + let iid = active.active_item_id(ws).context("No active item")?; + ws.move_item(sid, iid, sid, target)?; + let count = active.active_session(ws).map_or(0, |s| s.items.len()); + active.select_item(ws, target.min(count.saturating_sub(1)))?; + println!("Move Item To [{target}]"); + print_context(ws, active); + } + + PatchStep::ViewWorkspace => { + println!( + "View Workspace {:?} {} sessions", + ws.name, + ws.sessions.len() + ); + for s in &ws.sessions { + let m = if active.active_session_id() == Some(s.id) { + "=>" + } else { + " " + }; + println!( + " {m} {:?} ({:?}) {} items", + s.name, + s.session_type, + s.items.len() + ); + } + } + PatchStep::ViewSession(name) => { + let session = ws.session_by_name(&name)?; + println!( + "View Session {:?} ({:?}) {} items", + session.name, + session.session_type, + session.items.len() + ); + for (i, item) in session.items.iter().enumerate() { + let m = if active.active_session_id() == Some(session.id) + && active.active_item_index() == Some(i) + { + "=>" + } else { + " " + }; + let kind = match &item.kind { + ItemKind::Portable { .. } => "portable", + ItemKind::Raster { .. } => "raster", + ItemKind::Vector { .. } => "vector", + ItemKind::Document { .. } => "document", + }; + println!(" {m} [{i}] {:?} ({kind})", item.name); + } + } + } + Ok(()) +} + +fn print_context(ws: &Workspace, active: &ActiveState) { + let Some(session) = active.active_session(ws) else { + return; + }; + let item_info = match active.active_item(ws) { + Some(item) => format!( + "[{}] {:?}", + active.active_item_index().unwrap_or(0), + item.name + ), + None => "-".into(), + }; + println!( + " {:?} {} items active: {item_info}", + session.name, + session.items.len() + ); +} + +fn parse_session_type(s: &str) -> Result { + match s.to_lowercase().as_str() { + "browser" => Ok(SessionType::Browser), + "collection" => Ok(SessionType::Collection), + _ => bail!("Unknown session type: {s:?}"), + } +} + +fn parse_item_kind(kind: &str, path: &str) -> Result { + let path = PathBuf::from(path); + match kind.to_lowercase().as_str() { + "portable" | "pdf" => Ok(ItemKind::Portable { path }), + "raster" | "image" => Ok(ItemKind::Raster { path }), + "vector" | "svg" => Ok(ItemKind::Vector { path }), + "document" => Ok(ItemKind::Document { path }), + _ => bail!("Unknown item kind: {kind:?}"), + } +} diff --git a/src/workspace/manager.rs b/src/workspace/manager.rs new file mode 100644 index 0000000..3b17348 --- /dev/null +++ b/src/workspace/manager.rs @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/workspace/manager.rs +// +// Workspace operations: session and item CRUD, reordering, and RON persistence. + +use crate::workspace::model::{Item, Session, Workspace}; +use anyhow::{Context, Result}; +use std::path::Path; +use uuid::Uuid; + +impl Workspace { + /// Serialize the workspace to a RON file. + pub fn save(&self, path: impl AsRef) -> Result<()> { + let config = ron::ser::PrettyConfig::default(); + let data = + ron::ser::to_string_pretty(self, config).context("Failed to serialize workspace")?; + std::fs::write(&path, data).context("Failed to write workspace file")?; + Ok(()) + } + + /// Deserialize a workspace from a RON file. + pub fn load(path: impl AsRef) -> Result { + let data = std::fs::read_to_string(&path).context("Failed to read workspace file")?; + let workspace: Workspace = + ron::from_str(&data).context("Failed to deserialize workspace")?; + Ok(workspace) + } + + pub fn add_session(&mut self, session: Session) { + self.sessions.push(session); + } + + pub fn remove_session(&mut self, session_id: Uuid) -> Result { + let pos = self.session_pos(session_id)?; + Ok(self.sessions.remove(pos)) + } + + pub fn copy_session(&mut self, session_id: Uuid) -> Result { + let pos = self.session_pos(session_id)?; + let mut clone = self.sessions[pos].clone(); + clone.id = Uuid::new_v4(); + clone.name = format!("{} (copy)", clone.name); + for item in &mut clone.items { + item.id = Uuid::new_v4(); + } + let new_id = clone.id; + self.sessions.push(clone); + Ok(new_id) + } + + pub fn move_session(&mut self, session_id: Uuid, new_index: usize) -> Result<()> { + let pos = self.session_pos(session_id)?; + let session = self.sessions.remove(pos); + let insert_at = new_index.min(self.sessions.len()); + self.sessions.insert(insert_at, session); + Ok(()) + } + + pub fn rename_session(&mut self, session_id: Uuid, new_name: impl Into) -> Result<()> { + let session = self.session_mut(session_id)?; + session.name = new_name.into(); + Ok(()) + } + + pub fn add_item(&mut self, session_id: Uuid, item: Item) -> Result<()> { + let session = self.session_mut(session_id)?; + session.items.push(item); + Ok(()) + } + + pub fn remove_item(&mut self, session_id: Uuid, item_id: Uuid) -> Result { + let session = self.session_mut(session_id)?; + let pos = item_pos(&session.items, item_id)?; + Ok(session.items.remove(pos)) + } + + /// Move an item within the same session or between two sessions. + pub fn move_item( + &mut self, + from_session: Uuid, + item_id: Uuid, + to_session: Uuid, + to_index: usize, + ) -> Result<()> { + let item = self.remove_item(from_session, item_id)?; + let target = self.session_mut(to_session)?; + let insert_at = to_index.min(target.items.len()); + target.items.insert(insert_at, item); + Ok(()) + } + + /// Copy an item within the same session or between two sessions. + pub fn copy_item( + &mut self, + from_session: Uuid, + item_id: Uuid, + to_session: Uuid, + to_index: usize, + ) -> Result { + let source = self.session(from_session)?; + let pos = item_pos(&source.items, item_id)?; + let mut clone = source.items[pos].clone(); + clone.id = Uuid::new_v4(); + clone.name = format!("{} (copy)", clone.name); + let new_id = clone.id; + + let target = self.session_mut(to_session)?; + let insert_at = to_index.min(target.items.len()); + target.items.insert(insert_at, clone); + Ok(new_id) + } + + pub fn rename_item( + &mut self, + session_id: Uuid, + item_id: Uuid, + new_name: impl Into, + ) -> Result<()> { + let session = self.session_mut(session_id)?; + let pos = item_pos(&session.items, item_id)?; + session.items[pos].name = new_name.into(); + Ok(()) + } + + /// Read-only access to a session by id. + pub fn session(&self, id: Uuid) -> Result<&Session> { + self.sessions + .iter() + .find(|s| s.id == id) + .context(format!("Session {id} not found")) + } + + /// Read-only access to a session by name. + pub fn session_by_name(&self, name: &str) -> Result<&Session> { + self.sessions + .iter() + .find(|s| s.name == name) + .context(format!("Session {name:?} not found")) + } + + /// Find an item by name within a session. + pub fn item_by_name(&self, session_id: Uuid, name: &str) -> Result<&Item> { + let session = self.session(session_id)?; + session + .items + .iter() + .find(|i| i.name == name) + .context(format!( + "Item {name:?} not found in session {:?}", + session.name + )) + } + + fn session_mut(&mut self, id: Uuid) -> Result<&mut Session> { + self.sessions + .iter_mut() + .find(|s| s.id == id) + .context(format!("Session {id} not found")) + } + + fn session_pos(&self, id: Uuid) -> Result { + self.sessions + .iter() + .position(|s| s.id == id) + .context(format!("Session {id} not found")) + } +} + +fn item_pos(items: &[Item], id: Uuid) -> Result { + items + .iter() + .position(|i| i.id == id) + .context(format!("Item {id} not found")) +} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs new file mode 100644 index 0000000..330ed70 --- /dev/null +++ b/src/workspace/mod.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/workspace/mod.rs +// +// Workspace subsystem: data model, operations, and persistence. + +pub mod manager; +pub mod model; diff --git a/src/workspace/model.rs b/src/workspace/model.rs new file mode 100644 index 0000000..4d0e008 --- /dev/null +++ b/src/workspace/model.rs @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/workspace/model.rs +// +// Pure data structures for the workspace, sessions, and their items. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use uuid::Uuid; + +/// The document kind an item references. +/// +/// Each variant carries the path to the source file. Format-specific fields +/// (e.g. page index for PDFs) live on the variant so they can differ per kind. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ItemKind { + Portable { path: PathBuf }, + Raster { path: PathBuf }, + Vector { path: PathBuf }, + Document { path: PathBuf }, +} + +impl ItemKind { + /// Returns the file path regardless of the variant. + pub fn path(&self) -> &PathBuf { + match self { + ItemKind::Portable { path } + | ItemKind::Raster { path } + | ItemKind::Vector { path } + | ItemKind::Document { path } => path, + } + } +} + +/// A single entry in a session. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Item { + pub id: Uuid, + pub name: String, + pub kind: ItemKind, + pub tags: Vec, +} + +impl Item { + pub fn new(name: impl Into, kind: ItemKind) -> Self { + Self { + id: Uuid::new_v4(), + name: name.into(), + kind, + tags: Vec::new(), + } + } + + pub fn with_tags(mut self, tags: Vec) -> Self { + self.tags = tags; + self + } +} + +/// Determines how a session behaves in the UI. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum SessionType { + Browser, + Collection, +} + +/// A named group of items inside a workspace. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub id: Uuid, + pub name: String, + pub session_type: SessionType, + pub items: Vec, +} + +impl Session { + pub fn new(name: impl Into, session_type: SessionType) -> Self { + Self { + id: Uuid::new_v4(), + name: name.into(), + session_type, + items: Vec::new(), + } + } +} + +/// The top-level container that is persisted as a `.ws.ron` file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Workspace { + pub name: String, + pub sessions: Vec, +} + +impl Workspace { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + sessions: Vec::new(), + } + } +} diff --git a/test.json b/test.json new file mode 100644 index 0000000..4dd1247 --- /dev/null +++ b/test.json @@ -0,0 +1,94 @@ +[ + { "NewWorkspace": "Demo Projekt" }, + + { "CreateSession": { "name": "Referenzen", "session_type": "browser" } }, + { + "AddItem": { + "session": "Referenzen", + "name": "Logo", + "kind": "vector", + "path": "tmp/pictures/logo.svg" + } + }, + { + "AddItem": { + "session": "Referenzen", + "name": "Foto Hintergrund", + "kind": "raster", + "path": "tmp/pictures/hero.jpg" + } + }, + { + "AddItem": { + "session": "Referenzen", + "name": "Handbuch", + "kind": "portable", + "path": "tmp/pictures/handbook.pdf" + } + }, + { "ViewSession": "Referenzen" }, + + { "SelectSession": "Referenzen" }, + { "SelectItem": 0 }, + "NextItem", + "NextItem", + "PreviousItem", + { "SelectItem": 0 }, + "MoveItemDown", + { "ViewSession": "Referenzen" }, + "MoveItemUp", + { "ViewSession": "Referenzen" }, + { "MoveItemToIndex": 2 }, + { "ViewSession": "Referenzen" }, + { "MoveItemToIndex": 0 }, + + { "CreateSession": { "name": "Favoriten", "session_type": "collection" } }, + { "CreateSession": { "name": "Archiv", "session_type": "collection" } }, + "ViewWorkspace", + + { "CopyItem": { "from": "Referenzen", "item": "Logo", "to": "Favoriten" } }, + { + "CopyItem": { + "from": "Referenzen", + "item": "Foto Hintergrund", + "to": "Favoriten" + } + }, + { "ViewSession": "Favoriten" }, + + { + "RenameItem": { + "session": "Favoriten", + "item": "Logo (copy)", + "name": "Hauptlogo" + } + }, + { "ViewSession": "Favoriten" }, + + { + "RemoveItem": { "session": "Favoriten", "item": "Foto Hintergrund (copy)" } + }, + { "ViewSession": "Favoriten" }, + + { "MoveItem": { "from": "Referenzen", "item": "Handbuch", "to": "Archiv" } }, + { "ViewSession": "Referenzen" }, + { "ViewSession": "Archiv" }, + + { "CopySession": "Referenzen" }, + "ViewWorkspace", + + { + "RenameSession": { + "session": "Referenzen (copy)", + "name": "Backup Referenzen" + } + }, + "ViewWorkspace", + + { "DeleteSession": "Archiv" }, + "ViewWorkspace", + + { "SaveWorkspace": "tmp/demo.ws.ron" }, + { "LoadWorkspace": "tmp/demo.ws.ron" }, + "ViewWorkspace" +]