refactor: workspace/session/item ... ja ja, i know but now it is nailed.
This commit is contained in:
parent
ca6f8f788f
commit
79cc199d11
6 changed files with 878 additions and 0 deletions
165
src/active_state.rs
Normal file
165
src/active_state.rs
Normal file
|
|
@ -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<Uuid>,
|
||||||
|
active_item: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Uuid> {
|
||||||
|
self.active_session
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn active_item_index(&self) -> Option<usize> {
|
||||||
|
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<Uuid> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
338
src/patch.rs
Normal file
338
src/patch.rs
Normal file
|
|
@ -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<Vec<PatchStep>> {
|
||||||
|
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<SessionType> {
|
||||||
|
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<ItemKind> {
|
||||||
|
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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
174
src/workspace/manager.rs
Normal file
174
src/workspace/manager.rs
Normal file
|
|
@ -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<Path>) -> 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<Path>) -> Result<Self> {
|
||||||
|
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<Session> {
|
||||||
|
let pos = self.session_pos(session_id)?;
|
||||||
|
Ok(self.sessions.remove(pos))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn copy_session(&mut self, session_id: Uuid) -> Result<Uuid> {
|
||||||
|
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<String>) -> 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<Item> {
|
||||||
|
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<Uuid> {
|
||||||
|
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<String>,
|
||||||
|
) -> 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<usize> {
|
||||||
|
self.sessions
|
||||||
|
.iter()
|
||||||
|
.position(|s| s.id == id)
|
||||||
|
.context(format!("Session {id} not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn item_pos(items: &[Item], id: Uuid) -> Result<usize> {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.position(|i| i.id == id)
|
||||||
|
.context(format!("Item {id} not found"))
|
||||||
|
}
|
||||||
7
src/workspace/mod.rs
Normal file
7
src/workspace/mod.rs
Normal file
|
|
@ -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;
|
||||||
100
src/workspace/model.rs
Normal file
100
src/workspace/model.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Item {
|
||||||
|
pub fn new(name: impl Into<String>, kind: ItemKind) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: name.into(),
|
||||||
|
kind,
|
||||||
|
tags: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_tags(mut self, tags: Vec<String>) -> 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<Item>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
pub fn new(name: impl Into<String>, 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<Session>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Workspace {
|
||||||
|
pub fn new(name: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
sessions: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
test.json
Normal file
94
test.json
Normal file
|
|
@ -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"
|
||||||
|
]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue