Setzt die Entscheidungen aus #2, #4 und #6 um: Der Knopf "Workspace hinzufuegen..." oeffnet den nativen Ordner-Dialog, legt den Marker an und uebernimmt den Workspace in Context, Liste und Editor. Der Marker ist .dionotes/workspace.ron mit genau (schema_version: 1). Er wird additiv angelegt, vorhandene Dateien bleiben unberuehrt - ein Test haelt das fest. Ein beschaedigter Marker fuehrt dazu, dass der Workspace nicht geoeffnet und der Marker nicht neu geschrieben wird; auch das ist getestet, denn sobald dort ein Passwort-Hash liegt, waere stillschweigendes Neuanlegen ein Weg, den Schutz auszuhebeln. OpenError unterscheidet die drei Zustaende aus #6 - nicht erreichbar, kein Workspace mehr, beschaedigt - plus den Fall eines zu neuen Schemas. Die Display-Impl liefert genau die Formulierungen, mit denen spaeter die Listeneintraege markiert werden. Der Flow folgt der Regel aus #6: Ein abgebrochener Dialog ist kein Fehler und erzeugt keine Meldung. Ein Ordner, der bereits Workspace ist, wird geoeffnet statt abgelehnt - derselbe Knopf nimmt neue und wiedergefundene Workspaces auf, was seine Beschriftung erst rechtfertigt. Ein nicht leerer Ordner loest eine Rueckfrage aus, die ausdruecklich sagt, dass nichts Vorhandenes veraendert wird; das ist beim Import der Normalfall, keine Ausnahme. rfd wird mit denselben Features eingebunden, die dioxus-desktop ohnehin zieht (xdg-portal statt default). Mit den Default-Features waeren neun Wayland-Pakete dazugekommen, die das Portal nicht braucht - so kostet die explizite Abhaengigkeit keinen einzigen zusaetzlichen Eintrag.
223 lines
7.9 KiB
Rust
223 lines
7.9 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::fmt;
|
|
use std::fs;
|
|
use std::io;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
const MARKER_DIR: &str = ".dionotes";
|
|
const MARKER_FILE: &str = "workspace.ron";
|
|
|
|
/// Aktuelle Fassung des Marker-Schemas. Erhöhen, sobald Felder dazukommen,
|
|
/// die eine ältere App nicht bloß ignorieren kann.
|
|
const SCHEMA_VERSION: u32 = 1;
|
|
|
|
/// Inhalt von `.dionotes/workspace.ron`.
|
|
///
|
|
/// Heute genau ein Feld. Kein `created_at` — das Erstellungsdatum kennt das
|
|
/// Dateisystem, es hier zu duplizieren schafft eine zweite Wahrheit, die beim
|
|
/// Kopieren des Ordners auseinanderläuft. Weitere Felder entstehen mit dem
|
|
/// Feature, das sie braucht; `#[serde(default)]` macht das bruchfrei.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
struct Marker {
|
|
schema_version: u32,
|
|
}
|
|
|
|
/// Ein geöffneter Workspace — der Ordner auf der Platte, in dem die Notizen liegen.
|
|
///
|
|
/// Heute genau ein Feld. Der Typ existiert trotzdem als eigener Name, weil
|
|
/// `CONTEXT.md` „Workspace" als Domänenbegriff führt und die Einstellungen aus
|
|
/// `.dionotes/workspace.ron` hier andocken werden.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct Workspace {
|
|
pub root: PathBuf,
|
|
}
|
|
|
|
/// Warum ein Ordner sich nicht als Workspace öffnen ließ.
|
|
///
|
|
/// Die ersten drei Varianten sind genau die Zustände, mit denen ein
|
|
/// Listeneintrag markiert wird — sie führen zu verschiedenen Handlungen:
|
|
/// Laufwerk einhängen, Ordner wiederherstellen, Datei reparieren.
|
|
#[derive(Debug)]
|
|
pub enum OpenError {
|
|
/// Der Ordner existiert nicht. Meist ein abgezogener Stick oder ein nicht
|
|
/// eingehängtes Netzlaufwerk — deshalb wird der Eintrag nie automatisch
|
|
/// aus der Liste entfernt.
|
|
Unreachable,
|
|
/// Der Ordner ist da, trägt aber keinen Marker.
|
|
NotAWorkspace,
|
|
/// Marker vorhanden, aber nicht lesbar. Der Workspace wird **nicht**
|
|
/// geöffnet und der Marker **nicht** neu angelegt: Sobald dort ein
|
|
/// Passwort-Hash liegt, wäre stillschweigendes Neuanlegen ein Weg, den
|
|
/// Schutz auszuhebeln.
|
|
Damaged,
|
|
/// Der Marker stammt aus einer neueren Fassung der App.
|
|
TooNew { found: u32 },
|
|
Io(io::Error),
|
|
}
|
|
|
|
impl fmt::Display for OpenError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Unreachable => write!(f, "nicht erreichbar"),
|
|
Self::NotAWorkspace => write!(f, "kein Workspace mehr"),
|
|
Self::Damaged => write!(f, "beschädigt"),
|
|
Self::TooNew { found } => write!(
|
|
f,
|
|
"mit einer neueren Version angelegt (Schema {found}, unterstützt wird {SCHEMA_VERSION})"
|
|
),
|
|
Self::Io(err) => write!(f, "{err}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<io::Error> for OpenError {
|
|
fn from(err: io::Error) -> Self {
|
|
Self::Io(err)
|
|
}
|
|
}
|
|
|
|
impl Workspace {
|
|
/// Anzeigename: der Ordnername. Bewusst abgeleitet statt gespeichert —
|
|
/// ein eigenes Namensfeld wäre nach dem ersten Umbenennen des Ordners falsch.
|
|
pub fn name(&self) -> String {
|
|
self.root
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| self.root.to_string_lossy().into_owned())
|
|
}
|
|
|
|
fn marker_path(root: &Path) -> PathBuf {
|
|
root.join(MARKER_DIR).join(MARKER_FILE)
|
|
}
|
|
|
|
/// Ob dieser Ordner einen Marker trägt — die Frage „ist das hier überhaupt
|
|
/// ein Workspace?". Sagt nichts darüber, ob der Marker lesbar ist.
|
|
pub fn has_marker(root: &Path) -> bool {
|
|
Self::marker_path(root).is_file()
|
|
}
|
|
|
|
/// Einen bestehenden Workspace öffnen.
|
|
pub fn open(root: PathBuf) -> Result<Self, OpenError> {
|
|
if !root.is_dir() {
|
|
return Err(OpenError::Unreachable);
|
|
}
|
|
let marker_path = Self::marker_path(&root);
|
|
if !marker_path.is_file() {
|
|
return Err(OpenError::NotAWorkspace);
|
|
}
|
|
|
|
let text = fs::read_to_string(&marker_path).map_err(|_| OpenError::Damaged)?;
|
|
let marker: Marker = ron::from_str(&text).map_err(|_| OpenError::Damaged)?;
|
|
if marker.schema_version > SCHEMA_VERSION {
|
|
return Err(OpenError::TooNew { found: marker.schema_version });
|
|
}
|
|
|
|
Ok(Self { root })
|
|
}
|
|
|
|
/// Einen Ordner zum Workspace machen: `.dionotes/workspace.ron` anlegen.
|
|
///
|
|
/// Vorhandene Dateien im Ordner bleiben unberührt — der Marker ist additiv.
|
|
pub fn create(root: PathBuf) -> Result<Self, OpenError> {
|
|
if !root.is_dir() {
|
|
return Err(OpenError::Unreachable);
|
|
}
|
|
let marker_path = Self::marker_path(&root);
|
|
if let Some(dir) = marker_path.parent() {
|
|
fs::create_dir_all(dir)?;
|
|
}
|
|
let marker = Marker { schema_version: SCHEMA_VERSION };
|
|
let text = ron::ser::to_string_pretty(&marker, ron::ser::PrettyConfig::default())
|
|
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
|
fs::write(&marker_path, text)?;
|
|
Ok(Self { root })
|
|
}
|
|
}
|
|
|
|
/// Ob ein Ordner leer ist — Grundlage für die Rückfrage vor dem Anlegen.
|
|
pub fn dir_is_empty(path: &Path) -> io::Result<bool> {
|
|
Ok(fs::read_dir(path)?.next().is_none())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{dir_is_empty, OpenError, Workspace};
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
/// Frisches Testverzeichnis. Der Testname macht es eindeutig, deshalb
|
|
/// braucht es keine Zufallszahl und keine tempfile-Crate.
|
|
fn scratch(name: &str) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!("dionotes-test-{name}"));
|
|
let _ = fs::remove_dir_all(&dir);
|
|
fs::create_dir_all(&dir).expect("Testverzeichnis anlegbar");
|
|
dir
|
|
}
|
|
|
|
#[test]
|
|
fn name_is_the_folder_name() {
|
|
let ws = Workspace { root: PathBuf::from("C:/Users/marvin/Notizen") };
|
|
assert_eq!(ws.name(), "Notizen");
|
|
}
|
|
|
|
#[test]
|
|
fn create_then_open_roundtrip() {
|
|
let dir = scratch("create-open");
|
|
let created = Workspace::create(dir.clone()).expect("anlegbar");
|
|
assert!(Workspace::has_marker(&dir));
|
|
assert_eq!(Workspace::open(dir.clone()).expect("oeffenbar"), created);
|
|
}
|
|
|
|
#[test]
|
|
fn create_leaves_existing_files_alone() {
|
|
let dir = scratch("create-keeps-files");
|
|
fs::write(dir.join("notiz.md"), "# Hallo").unwrap();
|
|
Workspace::create(dir.clone()).expect("anlegbar");
|
|
assert_eq!(fs::read_to_string(dir.join("notiz.md")).unwrap(), "# Hallo");
|
|
}
|
|
|
|
#[test]
|
|
fn missing_folder_is_unreachable() {
|
|
let dir = scratch("gone").join("weg");
|
|
assert!(matches!(Workspace::open(dir), Err(OpenError::Unreachable)));
|
|
}
|
|
|
|
#[test]
|
|
fn folder_without_marker_is_not_a_workspace() {
|
|
let dir = scratch("no-marker");
|
|
assert!(matches!(Workspace::open(dir), Err(OpenError::NotAWorkspace)));
|
|
}
|
|
|
|
#[test]
|
|
fn unreadable_marker_is_damaged_and_not_recreated() {
|
|
let dir = scratch("damaged");
|
|
Workspace::create(dir.clone()).expect("anlegbar");
|
|
let marker = dir.join(".dionotes").join("workspace.ron");
|
|
fs::write(&marker, "kaputt").unwrap();
|
|
|
|
assert!(matches!(Workspace::open(dir), Err(OpenError::Damaged)));
|
|
// Der Marker wurde nicht stillschweigend ersetzt.
|
|
assert_eq!(fs::read_to_string(&marker).unwrap(), "kaputt");
|
|
}
|
|
|
|
#[test]
|
|
fn newer_schema_is_refused_with_the_version() {
|
|
let dir = scratch("too-new");
|
|
Workspace::create(dir.clone()).expect("anlegbar");
|
|
fs::write(dir.join(".dionotes").join("workspace.ron"), "(schema_version: 99)").unwrap();
|
|
|
|
match Workspace::open(dir) {
|
|
Err(OpenError::TooNew { found }) => assert_eq!(found, 99),
|
|
other => panic!("erwartet TooNew, war {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn empty_folder_is_recognised() {
|
|
let dir = scratch("emptiness");
|
|
assert!(dir_is_empty(&dir).unwrap());
|
|
fs::write(dir.join("etwas.txt"), "x").unwrap();
|
|
assert!(!dir_is_empty(&dir).unwrap());
|
|
}
|
|
}
|