Compare commits
2 Commits
5013f4bcc4
...
docs/reche
| Author | SHA1 | Date | |
|---|---|---|---|
| d470756920 | |||
| c8cf525ec5 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -9,9 +9,11 @@
|
||||
# Dioxus build output
|
||||
/dist
|
||||
|
||||
# Node (tailwind toolchain)
|
||||
node_modules/
|
||||
|
||||
# IDE
|
||||
/.idea
|
||||
|
||||
# dx erzeugt diese Datei bei jedem vollen Build neu, auch ohne Eingabedatei
|
||||
# und ohne dass sie noch verlinkt waere. Lokales Nebenprodukt, kein Quellcode.
|
||||
assets/tailwind.css
|
||||
# Claude Code (Arbeitsverzeichnis, lokale Einstellungen und Worktrees)
|
||||
/.claude
|
||||
|
||||
61
README.md
61
README.md
@@ -1,32 +1,51 @@
|
||||
# DioNotes Client
|
||||
# Development
|
||||
|
||||
Lokale Markdown-Notizen-App auf Basis von [Dioxus 0.7](https://dioxuslabs.com/learn/0.7), Desktop.
|
||||
Your new bare-bones project includes minimal organization with a single `main.rs` file and a few assets.
|
||||
|
||||
## Entwicklung
|
||||
```
|
||||
project/
|
||||
├─ assets/ # Any assets that are used by the app should be placed here
|
||||
├─ src/
|
||||
│ ├─ main.rs # main.rs is the entry point to your application and currently contains all components for the app
|
||||
├─ Cargo.toml # The Cargo.toml file defines the dependencies and feature flags for your project
|
||||
```
|
||||
|
||||
### Automatic Tailwind (Dioxus 0.7+)
|
||||
|
||||
As of Dioxus 0.7, there no longer is a need to manually install tailwind. Simply `dx serve` and you're good to go!
|
||||
|
||||
Automatic tailwind is supported by checking for a file called `tailwind.css` in your app's manifest directory (next to Cargo.toml). To customize the file, use the dioxus.toml:
|
||||
|
||||
```toml
|
||||
[application]
|
||||
tailwind_input = "my.css"
|
||||
tailwind_output = "assets/out.css" # also customize the location of the out file!
|
||||
```
|
||||
|
||||
### Tailwind Manual Install
|
||||
|
||||
To use tailwind plugins or manually customize tailwind, you can can install the Tailwind CLI and use it directly.
|
||||
|
||||
### Tailwind
|
||||
1. Install npm: https://docs.npmjs.com/downloading-and-installing-node-js-and-npm
|
||||
2. Install the Tailwind CSS CLI: https://tailwindcss.com/docs/installation/tailwind-cli
|
||||
3. Run the following command in the root of the project to start the Tailwind CSS compiler:
|
||||
|
||||
```bash
|
||||
dx serve # Desktop-App starten (Standard-Plattform)
|
||||
dx serve --platform web # im Browser
|
||||
cargo test # Tests
|
||||
npx @tailwindcss/cli -i ./input.css -o ./assets/tailwind.css --watch
|
||||
```
|
||||
|
||||
## Aufbau
|
||||
### Serving Your App
|
||||
|
||||
```
|
||||
assets/css/ Design-System: tokens → base → layout → components,
|
||||
in dieser Reihenfolge in main.rs verlinkt
|
||||
src/route.rs alle Routen in einem Routable-Enum
|
||||
src/workspace.rs
|
||||
src/components/ wiederverwendbare Bausteine (CustomLink, CustomButton, …)
|
||||
src/views/ die Bildschirme: Selector und Editor
|
||||
docs/ Konventionen für Agenten, Rechercheergebnisse
|
||||
Run the following command in the root of your project to start developing with the default platform:
|
||||
|
||||
```bash
|
||||
dx serve
|
||||
```
|
||||
|
||||
Es gibt **kein** Tailwind und keine npm-Toolchain — das Styling liegt vollständig in
|
||||
`assets/css/` und arbeitet mit den `--dn-*`-Tokens.
|
||||
To run for a different platform, use the `--platform platform` flag. E.g.
|
||||
```bash
|
||||
dx serve --platform desktop
|
||||
```
|
||||
|
||||
## Wo die Entscheidungen liegen
|
||||
|
||||
Fachbegriffe stehen in [`CONTEXT.md`](CONTEXT.md). Entscheidungen werden im
|
||||
Issue-Tracker geführt statt in `docs/adr/`; eine Wayfinder-Map indexiert pro Vorhaben,
|
||||
welche Entscheidung wo begründet ist. Siehe [`docs/agents/`](docs/agents/).
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
Palette: Catppuccin Mocha, matching the editor's Theme::CATPPUCCIN_MOCHA
|
||||
so the editor pane and the app chrome are the same surface.
|
||||
|
||||
All tokens are prefixed --dn- to keep them in one namespace. The prefix was
|
||||
originally defence against Tailwind v4, which publishes its own --font-*,
|
||||
--text-* and --radius-* on :root; Tailwind is gone, the convention stays.
|
||||
All tokens are prefixed --dn-. Tailwind v4 publishes its own --font-*,
|
||||
--text-*, --radius-* and --leading-* on :root and tree-shakes that set as
|
||||
utilities come and go, so an unprefixed token would collide silently.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
|
||||
275
assets/tailwind.css
Normal file
275
assets/tailwind.css
Normal file
@@ -0,0 +1,275 @@
|
||||
/*! tailwindcss v4.1.5 | MIT License | https://tailwindcss.com */
|
||||
@layer properties;
|
||||
@layer theme, base, components, utilities;
|
||||
@layer theme {
|
||||
:root, :host {
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--default-transition-duration: 150ms;
|
||||
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--default-font-family: var(--font-sans);
|
||||
--default-mono-font-family: var(--font-mono);
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
*, ::after, ::before, ::backdrop, ::file-selector-button {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0 solid;
|
||||
}
|
||||
html, :host {
|
||||
line-height: 1.5;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
tab-size: 4;
|
||||
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
|
||||
font-feature-settings: var(--default-font-feature-settings, normal);
|
||||
font-variation-settings: var(--default-font-variation-settings, normal);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
hr {
|
||||
height: 0;
|
||||
color: inherit;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
abbr:where([title]) {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
-webkit-text-decoration: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
b, strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
code, kbd, samp, pre {
|
||||
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
|
||||
font-feature-settings: var(--default-mono-font-feature-settings, normal);
|
||||
font-variation-settings: var(--default-mono-font-variation-settings, normal);
|
||||
font-size: 1em;
|
||||
}
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
sub, sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
table {
|
||||
text-indent: 0;
|
||||
border-color: inherit;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
:-moz-focusring {
|
||||
outline: auto;
|
||||
}
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
ol, ul, menu {
|
||||
list-style: none;
|
||||
}
|
||||
img, svg, video, canvas, audio, iframe, embed, object {
|
||||
display: block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
img, video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
button, input, select, optgroup, textarea, ::file-selector-button {
|
||||
font: inherit;
|
||||
font-feature-settings: inherit;
|
||||
font-variation-settings: inherit;
|
||||
letter-spacing: inherit;
|
||||
color: inherit;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
opacity: 1;
|
||||
}
|
||||
:where(select:is([multiple], [size])) optgroup {
|
||||
font-weight: bolder;
|
||||
}
|
||||
:where(select:is([multiple], [size])) optgroup option {
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
::file-selector-button {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
::placeholder {
|
||||
opacity: 1;
|
||||
}
|
||||
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
|
||||
::placeholder {
|
||||
color: currentcolor;
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
color: color-mix(in oklab, currentcolor 50%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
::-webkit-date-and-time-value {
|
||||
min-height: 1lh;
|
||||
text-align: inherit;
|
||||
}
|
||||
::-webkit-datetime-edit {
|
||||
display: inline-flex;
|
||||
}
|
||||
::-webkit-datetime-edit-fields-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
|
||||
padding-block: 0;
|
||||
}
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button {
|
||||
appearance: button;
|
||||
}
|
||||
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
[hidden]:where(:not([hidden='until-found'])) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@layer utilities {
|
||||
.visible {
|
||||
visibility: visible;
|
||||
}
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
.static {
|
||||
position: static;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
@media (width >= 40rem) {
|
||||
max-width: 40rem;
|
||||
}
|
||||
@media (width >= 48rem) {
|
||||
max-width: 48rem;
|
||||
}
|
||||
@media (width >= 64rem) {
|
||||
max-width: 64rem;
|
||||
}
|
||||
@media (width >= 80rem) {
|
||||
max-width: 80rem;
|
||||
}
|
||||
@media (width >= 96rem) {
|
||||
max-width: 96rem;
|
||||
}
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.table {
|
||||
display: table;
|
||||
}
|
||||
.border-collapse {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.transform {
|
||||
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
|
||||
}
|
||||
.resize {
|
||||
resize: both;
|
||||
}
|
||||
.border {
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 1px;
|
||||
}
|
||||
.text-wrap {
|
||||
text-wrap: wrap;
|
||||
}
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.outline {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
}
|
||||
.transition {
|
||||
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, visibility, content-visibility, overlay, pointer-events;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
}
|
||||
@property --tw-rotate-x {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-rotate-y {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-rotate-z {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-skew-x {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-skew-y {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-border-style {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: solid;
|
||||
}
|
||||
@property --tw-outline-style {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: solid;
|
||||
}
|
||||
@layer properties {
|
||||
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
|
||||
*, ::before, ::after, ::backdrop {
|
||||
--tw-rotate-x: initial;
|
||||
--tw-rotate-y: initial;
|
||||
--tw-rotate-z: initial;
|
||||
--tw-skew-x: initial;
|
||||
--tw-skew-y: initial;
|
||||
--tw-border-style: solid;
|
||||
--tw-outline-style: solid;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,27 +61,6 @@ Create a Gitea issue.
|
||||
|
||||
Run `tea issues <index>` plus `tea comments ls <index>`.
|
||||
|
||||
## Branch- und PR-Workflow
|
||||
|
||||
Änderungen werden **nicht direkt auf `main`** committet, sondern auf einem eigenen Branch
|
||||
pro logischer Einheit — `feat/`, `fix/`, `refactor/`, `chore/`, `docs/`. Eine Einheit ist,
|
||||
was man in einem Satz begründen kann; wenn die Commit-Nachricht „und außerdem" braucht,
|
||||
sind es zwei Branches.
|
||||
|
||||
Zurück nach `main` geht es über einen **Pull Request in Gitea**, nicht per lokalem Merge:
|
||||
|
||||
```bash
|
||||
git push -u origin <branch>
|
||||
tea pulls create --title "..." --description "..."
|
||||
```
|
||||
|
||||
`--description` nimmt wie bei Issues nur eine Zeichenkette; für längere Texte eine Datei
|
||||
schreiben und mit `$(cat datei)` übergeben. Bezieht sich der PR auf ein Ticket, gehört
|
||||
`Closes #<n>` in die Beschreibung, damit Gitea es beim Mergen schließt.
|
||||
|
||||
Gemergt wird in der Weboberfläche — das Mergen ist eine Entscheidung des Menschen, nicht
|
||||
Teil der Umsetzung.
|
||||
|
||||
## Wayfinding operations
|
||||
|
||||
Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets.
|
||||
|
||||
941
docs/research/historie-nachruesten.md
Normal file
941
docs/research/historie-nachruesten.md
Normal file
@@ -0,0 +1,941 @@
|
||||
# Was es kostet, Historie später möglich zu halten
|
||||
|
||||
> Research zu Gitea-Issue [#22](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/22). Stand: 2026-08-28.
|
||||
> Recherchiert gegen Primärquellen (Hersteller-Doku, Quellcode auf GitHub, Microsoft Learn, docs.rs, crates.io).
|
||||
> **Nichts davon wurde gebaut oder kompiliert.** Es wurde kein Anwendungscode geschrieben und keine Dependency
|
||||
> hinzugefügt. Alles unter „Belegt" stammt aus der jeweils genannten Quelle; alles Abgeleitete ist als solches
|
||||
> markiert.
|
||||
>
|
||||
> Historie ist **nicht geplant**. Dieses Dokument beschafft nur die Fakten für die Schreib-Entscheidung in
|
||||
> [#19](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/19).
|
||||
|
||||
---
|
||||
|
||||
## Kurzantwort
|
||||
|
||||
**Nein — ein Schreibverfahren „temporäre Datei schreiben, dann umbenennen" schließt eine spätere Historie
|
||||
nicht aus. Es ist im Gegenteil das *einzige* der beiden zur Wahl stehenden Verfahren, das Historie
|
||||
überhaupt billig macht.**
|
||||
|
||||
Die Begründung in einem Satz: Historie braucht die **Vorfassung**, und beim Umbenennen liegt die Vorfassung
|
||||
zum Zeitpunkt des Schreibens noch unversehrt auf der Platte — ein direktes `fs::write` hat sie da schon
|
||||
vernichtet.
|
||||
|
||||
Der Einwand aus der Beratungsrunde trifft eine reale Gefahr, aber am falschen Ort. Teuer wird Historie nicht
|
||||
durch das **Wie** des Schreibens, sondern durch das **Wo**: Wenn nach einem halben Jahr fünf Stellen im Code
|
||||
eine Notiz auf die Platte schreiben, muss man fünf Stellen anfassen. Wenn es genau eine gibt, ist Historie
|
||||
eine Änderung *innerhalb dieser einen Funktion* — und alles Weitere (Format, Ablageort, Aufbewahrung) eine
|
||||
Entscheidung, die man dann trifft, nicht heute.
|
||||
|
||||
Der Preis dafür, Historie möglich zu halten, beträgt also: **eine Funktion.** Keine Datei, kein Format, kein
|
||||
Byte auf der Platte, keine Dependency.
|
||||
|
||||
Und die Asymmetrie läuft genau andersherum als behauptet: Nicht das schlichte Schreiben ist die schwer
|
||||
umkehrbare Entscheidung, sondern **der Append-Log**. Von „Datei ist die Wahrheit" zu „Datei plus Sicherungen"
|
||||
ist eine Ergänzung. Von „Log ist die Wahrheit" zurück ist eine Migration. Wer die Option offenhalten will,
|
||||
hält sie gerade dadurch offen, dass er den Log **nicht** baut.
|
||||
|
||||
Dass der Log die Einbahnstraße ist, ist dabei nicht nur ein Architektur-Argument: Die Automerge-Maintainer
|
||||
schreiben selbst, dass sich Historie **nicht verwerfen lässt, ohne das Dokument zu ersetzen**, und Yjs
|
||||
begründet in seiner README, warum Tombstones konstruktionsbedingt liegenbleiben müssen (3.2).
|
||||
|
||||
---
|
||||
|
||||
## 1. Was Historie technisch überhaupt braucht
|
||||
|
||||
Bevor man Kosten schätzt, lohnt es, die Anforderung auf ihre Bestandteile zu zerlegen. Eine Historie —
|
||||
gleich welcher Bauart — braucht genau vier Dinge:
|
||||
|
||||
1. **Einen Auslöser.** Ein Zeitpunkt, an dem entschieden wird: jetzt entsteht eine neue Fassung.
|
||||
2. **Die Vorfassung.** Der Inhalt, der gleich ersetzt wird.
|
||||
3. **Einen Ablageort.** Irgendwo müssen die alten Fassungen hin.
|
||||
4. **Eine Aufräumregel.** Ohne sie wächst der Ablageort unbegrenzt.
|
||||
|
||||
Nur **Punkt 1 und 2 hängen am Schreibverfahren.** Punkt 3 und 4 sind reine Folgeentscheidungen und komplett
|
||||
unabhängig davon, wie die `.md` auf die Platte kommt.
|
||||
|
||||
Und beide, Auslöser und Vorfassung, fallen beim temp+rename-Verfahren **ohnehin an**:
|
||||
|
||||
- Der **Auslöser** ist der Aufruf der Schreibfunktion. Präziser geht es nicht.
|
||||
- Die **Vorfassung** liegt in dem Moment, in dem die temporäre Datei fertig geschrieben ist und das Umbenennen
|
||||
ansteht, noch vollständig und unangetastet unter ihrem Pfad. Man muss sie nur lesen, bevor man umbenennt.
|
||||
|
||||
Bei einem direkten `fs::write` auf die Zieldatei ist die Vorfassung dagegen nach dem ersten Byte weg. Wer dort
|
||||
später Historie nachrüsten will, muss die Datei **zusätzlich vorher lesen** — was möglich, aber ein
|
||||
Extra-Schritt ist, den das rename-Verfahren geschenkt bekommt.
|
||||
|
||||
> **Nebenbefund, der #19 unabhängig von Historie betrifft:** [#20](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/20)
|
||||
> (Zeitstempel-Konflikt) verlangt ohnehin, dass die Schreibfunktion vor dem Schreiben den Zustand der
|
||||
> Zieldatei prüft. Wer dort statt eines `stat` gleich den Inhalt liest, hat die Vorfassung ohne
|
||||
> zusätzlichen Syscall in der Hand. Das ist kein Argument, es heute zu tun — nur der Beleg, dass es
|
||||
> später auch nichts kostet.
|
||||
|
||||
---
|
||||
|
||||
## 2. Wie vergleichbare lokale Markdown-Editoren es lösen
|
||||
|
||||
Kurzüberblick, Details und Belege in den Unterabschnitten:
|
||||
|
||||
| Werkzeug | Wo liegt die Historie? | Format | Intervall | Aufbewahrung | Neben den `.md`? |
|
||||
|---|---|---|---|---|---|
|
||||
| **Obsidian** (File recovery) | App-Systemordner, `%APPDATA%\Obsidian\` — **außerhalb** des Vault | Volltext-Snapshots | 5 Min (Default) | 7 Tage (Default) | **Nein** |
|
||||
| **Obsidian Sync** (Version history) | Auf Obsidians Servern | serverseitig | — | 1 Monat (Standard) / 12 Monate (Plus) | **Nein**, kostenpflichtig |
|
||||
| **Joplin** (Note History) | Tabelle `revisions` in `database.sqlite` | Diff-Kette (`diff-match-patch`, JSON) | 10 Min | 90 Tage (Default) | **Nein** — es gibt gar keine `.md` |
|
||||
| **Logseq** (0.10.9) | `logseq/bak/<datei>/` **im** Graph-Ordner | Volltext-Kopien, Zeitstempel im Namen | **nur bei Konflikt oder Schreibfehler**, nicht bei jedem Speichern | 6 Fassungen pro Datei | **Ja** |
|
||||
| **Zettlr** | — es gibt keine | — | — | — | — |
|
||||
| **Syncthing** (kein Editor, aber das beste Vorbild) | `.stversions/` **im** Sync-Ordner | Volltext-Kopien mit Zeitstempel im Namen | pro Änderung | konfigurierbar, logarithmisch ausgedünnt | **Ja** |
|
||||
|
||||
**Das auffälligste Muster:** Von den untersuchten Editoren legt **keiner** seine Historie neben die
|
||||
Notizdateien. Sie liegt entweder im App-Ordner (Obsidian), in einer Datenbank (Joplin) oder auf einem Server
|
||||
(Obsidian Sync). Wer neben den `.md` versioniert, ist ein Sync-Werkzeug, kein Editor.
|
||||
|
||||
### 2.1 Obsidian — Historie als aufgesetztes Core-Plugin, komplett außerhalb des Vault
|
||||
|
||||
Obsidian ist der direkteste Vergleichspunkt: lose `.md`-Dateien in einem Ordner, dasselbe Versprechen an den
|
||||
Nutzer, dieselbe Ausgangslage.
|
||||
|
||||
**Belegt** (Zitate aus der offiziellen Help-Seite <https://obsidian.md/help/plugins/file-recovery>):
|
||||
|
||||
- „File recovery is a core plugin that protects your work from accidental deletions, file corruption, or
|
||||
unwanted changes by automatically saving **complete snapshots** of your notes at regular intervals."
|
||||
- „**Snapshots capture the full content of your files, not just changes**, allowing you to restore any
|
||||
previous version." — also **Volltext, keine Diffs.**
|
||||
- „**By default, snapshots are saved a minimum of 5 minutes from each other, and kept for 7 days.**"
|
||||
- „**Snapshots are kept in the Global settings, outside of the vault**, to account for vault-related data
|
||||
loss. This means that **snapshots are stored with the absolute path to the note**. If you've moved your
|
||||
vault recently, you may need to move it back to the location where it was when the snapshot was taken."
|
||||
- „If you are using Obsidian Sync or other syncing services, **File recovery snapshots will not sync between
|
||||
devices. Snapshots are device-specific and remain local to each device.**"
|
||||
- „File recovery is **not a complete backup solution**."
|
||||
- Nur `.md` und `.canvas` sind wiederherstellbar.
|
||||
- Aufräumen automatisch nach Ablauf der Tagesgrenze, manuell über *Clear history*.
|
||||
|
||||
Der Ablageort ist damit belegt als der Electron-userData-Ordner der App —
|
||||
**Windows: `%APPDATA%\Obsidian\`** (<https://obsidian.md/help/data-storage>), **nicht** `.obsidian/` im Vault
|
||||
und **nicht** neben den Notizen.
|
||||
|
||||
**Vermutung (starkes Indiz, nicht wörtlich belegt):** Die Snapshots liegen in Obsidians IndexedDB/LevelDB.
|
||||
Die Doku nennt bei IndexedDB nur Sync-State und Metadata-Cache, führt aber für File recovery **dieselbe**
|
||||
Apple-Lockdown-Mode-Einschränkung auf wie für IndexedDB. Ein Serialisierungsformat ist nirgends dokumentiert;
|
||||
Obsidian ist Closed Source.
|
||||
|
||||
**Widerspruch in der Doku selbst, den man kennen sollte:** Der Abschnitt „Storage and performance" derselben
|
||||
Seite behauptet, Snapshots „only store changed files", während der Haupttext „full content […] not just
|
||||
changes" sagt. Plausible Auflösung (**Vermutung**): Nur *geänderte Dateien* werden überhaupt gesnapshottet,
|
||||
jede davon aber als Volltext.
|
||||
|
||||
**Zum Schreibverfahren:** Es gibt **keine offizielle Aussage**, ob Obsidian atomar (temp+rename) oder
|
||||
in-place schreibt. Belegt ist nur die Plugin-API-Semantik: `Vault.process` ist dokumentiert als „**Atomically
|
||||
read, modify, and save the contents of a note**" und garantiert, „that the file doesn't change between
|
||||
reading the current content and writing the updated content"
|
||||
(<https://docs.obsidian.md/Plugins/Vault>). Das ist eine **Sperre gegen konkurrierende Plugins innerhalb des
|
||||
Obsidian-Prozesses**, keine dokumentierte Aussage über `rename()`-Atomarität gegenüber dem Dateisystem —
|
||||
diese Unterscheidung macht die Doku nirgends explizit (**Lesart, nicht wörtlich belegt**). Der Changelog zu
|
||||
1.13.4 (30.07.2026) enthält den Eintrag „Fixed a rare race condition where a file could get stuck and fail to
|
||||
save until the app was restarted" (<https://obsidian.md/changelog/2026-07-30-desktop-v1.13.4/>) — ein Beleg
|
||||
dafür, dass der Save-Pfad auch nach über zehn Jahren nicht trivial ist.
|
||||
|
||||
**Warum das für #22 der wichtigste Befund ist:** Obsidians Historie ist ein **nachträglich aufgesetztes
|
||||
Plugin, das mit dem Schreibverfahren des Kerns nichts zu tun hat**. Es beobachtet Dateien und legt Kopien
|
||||
woanders ab. Genau das ist das „Historie ist eine Schicht *über* dem Schreiben"-Modell — von dem am längsten
|
||||
etablierten Werkzeug dieser Kategorie in Produktion vorgeführt.
|
||||
|
||||
**Der Preis, den Obsidian für diesen Ort zahlt** (alles wörtlich aus der Doku oben): absolute Pfade, die nach
|
||||
einem Vault-Umzug brechen; keine Mitnahme beim Backup; kein Sync zwischen Geräten. Ein Mittelweg —
|
||||
Historie in einem versteckten Ordner *im* Vault — hat von den untersuchten Werkzeugen keiner gewählt; für
|
||||
Obsidian existiert dazu ein offener Feature-Request
|
||||
(<https://forum.obsidian.md/t/file-recovery-store-file-snapshots-in-the-vault-folder-instead-of-the-obsidians-system-folder/116677>,
|
||||
**Sekundärquelle, nur als Hinweis**).
|
||||
|
||||
### 2.2 Joplin — der Gegenentwurf: keine `.md`, dafür Diff-Ketten in SQLite
|
||||
|
||||
Joplin ist als Vergleich nur begrenzt tauglich, weil es das Versprechen, das DioNotes gibt, gar nicht erst
|
||||
macht — aber es zeigt sehr genau, was man mit einer Datenbank *dahinter* gewinnt.
|
||||
|
||||
**Belegt** (<https://joplinapp.org/help/dev/spec/user_profile/>): `database.sqlite` ist „The central SQLite
|
||||
database file where **all notes, notebooks, tags, and metadata are stored**". Es gibt keine `.md`-Dateien.
|
||||
Unter Windows: `%UserProfile%\.config\joplin-desktop`.
|
||||
|
||||
**Belegt** (<https://joplinapp.org/help/apps/note_history/>):
|
||||
|
||||
- „All the apps save a version of the modified notes **every 10 minutes**."
|
||||
- Aufbewahrung „by default, for **90 days**".
|
||||
- „These revisions are then **synced across all the devices**." — anders als bei Obsidian.
|
||||
- Wiederherstellen kopiert in einen Ordner „Restored Notes", ohne die aktuelle Version zu verändern.
|
||||
- Bei unterschiedlichen Einstellungen pro Gerät gewinnt die **kürzeste** Aufbewahrungsdauer.
|
||||
|
||||
**Belegt aus dem Quellcode** (<https://github.com/laurent22/joplin/blob/dev/packages/lib/models/Revision.ts>,
|
||||
<https://github.com/laurent22/joplin/blob/dev/packages/lib/services/RevisionService.ts>,
|
||||
<https://github.com/laurent22/joplin/blob/dev/packages/lib/models/settings/builtInMetadata.ts>):
|
||||
|
||||
- Tabelle `revisions`, Felder u. a. `title_diff`, `body_diff`, `metadata_diff`, `item_id`, `parent_id`.
|
||||
- Diffs über **Google `diff-match-patch`**, serialisiert als JSON (`createTextPatch` →
|
||||
`JSON.stringify(dmp.patch_make(old, new))`).
|
||||
- Defaults: `revisionService.ttlDays` = **90**, `revisionService.intervalBetweenRevisions` = **10 Min**
|
||||
(nicht in der UI sichtbar), `revisionService.oldNoteInterval` = **7 Tage**.
|
||||
- Die erste Revision einer Notiz ist ein Volltext-Patch gegen den Leerstring; Folgerevisionen sind Diffs mit
|
||||
`parent_id` auf die Vorgängerin. `mergeDiffs()` läuft die Kette bis zur Wurzel zurück.
|
||||
- **Aufräumen mit Rebase am Kopf** — der interessanteste Teil. Kommentar wörtlich aus `Revision.ts`:
|
||||
|
||||
> „When deleting old revisions, we need to make sure that the oldest surviving revision is a 'merged' one
|
||||
> (as opposed to a diff from a now deleted revision). So every time we deleted a revision, we need to find
|
||||
> if there's a corresponding surviving revision and modify that revision into a 'merged' one."
|
||||
|
||||
Konkret: Die älteste überlebende Revision wird rekonstruiert und als Volltext-Patch gegen den Leerstring
|
||||
neu geschrieben, `parent_id` auf `''`. Damit bleibt die Kettenlänge auf das TTL-Fenster begrenzt.
|
||||
- Änderungen, die aus **Sync oder Entschlüsselung** stammen, werden ausgefiltert — nur echte lokale Edits
|
||||
erzeugen Revisionen.
|
||||
|
||||
**Die Lehre für DioNotes:** Diff-Ketten sind sparsam, aber nicht umsonst. Sie kosten eine Rekonstruktion in
|
||||
`O(Kettenlänge)` bei **jedem Anlegen und jedem Anzeigen** einer Revision, plus eine nicht triviale
|
||||
Rebase-Logik beim Aufräumen, plus einen dokumentierten Fehlerpfad, wenn eine zu löschende Revision noch
|
||||
verschlüsselt ist. Obsidian hat für dasselbe Feature Volltext gewählt und dafür die Aufbewahrung auf 7 statt
|
||||
90 Tage gesetzt. **Retention-Dauer und Speicherstrategie hängen direkt zusammen** — das ist die eigentliche
|
||||
Stellschraube, und sie liegt vollständig in der Zukunft.
|
||||
|
||||
### 2.3 Logseq — Sicherungskopien im Workspace, aber nur wenn es brenzlig wird
|
||||
|
||||
Logseq ist für DioNotes der **lehrreichste** Fall, weil es als einziges untersuchtes Werkzeug seine
|
||||
Sicherungskopien **neben die Notizen legt** — genau der Mittelweg aus der Ticket-Frage — und weil dabei
|
||||
sichtbar wird, wo dieser Weg scheuert.
|
||||
|
||||
> **Versionsvorbehalt:** Der `master`-Branch von Logseq ist inzwischen die DB-basierte Neuentwicklung ohne
|
||||
> `handler/file.cljs`. Alle Datei-relevanten Belege stammen deshalb aus **Tag `0.10.9`**, der letzten
|
||||
> dateibasierten Release-Linie.
|
||||
|
||||
**Belegt** — drei generierte Verzeichnisse im Graph-Ordner:
|
||||
|
||||
| Pfad | Zweck | Beleg |
|
||||
|---|---|---|
|
||||
| `logseq/bak` | Sicherung überschriebener Dateien | [`backup_file.cljs#L7`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/backup_file.cljs#L7) |
|
||||
| `logseq/version-files/local` | explizit angeforderte Versionen | [`backup_file.cljs#L8`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/backup_file.cljs#L8) |
|
||||
| `logseq/.recycle` | Papierkorb — Löschen ist ein `renameSync` dorthin | [`handler.cljs#L55-L67`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/handler.cljs#L55-L67) |
|
||||
|
||||
**Namenskonvention** — pro Datei ein eigener Unterordner, Zeitstempel im Dateinamen
|
||||
([`backup_file.cljs#L10-L51`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/backup_file.cljs#L10-L51)):
|
||||
|
||||
```
|
||||
<graph>/logseq/bak/pages/Foo/2024-01-15T10_23_45.678Z.Desktop.md
|
||||
└ relativer Pfad ┘└ ISO-8601, ":" → "_" ┘└ Plattform ┘
|
||||
```
|
||||
|
||||
**Der wichtigste Befund: Es wird NICHT bei jedem Speichern gesichert.** Es gibt genau drei Auslöser
|
||||
(**belegt**):
|
||||
|
||||
1. **Erkannte Abweichung zwischen Platte und DB beim Schreiben**
|
||||
([`fs/node.cljs#L54-L57`](https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/fs/node.cljs#L54-L57)) —
|
||||
und selbst dann filtert der Electron-Handler ein zweites Mal und sichert nur, wenn der Diff eine
|
||||
*Löschung* zeigt
|
||||
([`handler.cljs#L75-L80`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/handler.cljs#L75-L80)):
|
||||
```clojure
|
||||
(when (and (string? db-content) (string? new-content)
|
||||
(string-some-deleted? db-content new-content))
|
||||
(backup-file/backup-file repo :backup-dir path ...))
|
||||
```
|
||||
2. **Schreibfehler** (EBUSY, Lock, fehlende Rechte) — mit Notification „A backup file was saved to …"
|
||||
([`handler.cljs#L117-L140`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/handler.cljs#L117-L140)).
|
||||
3. **Externe Dateiänderung über den Watcher**, bevor sie in die DB übernommen wird — Quellcode-Kommentar
|
||||
wörtlich: *„save the previous content in a versioned bak file to avoid data overwritten."*
|
||||
([`watcher_handler.cljs#L44-L56`](https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/fs/watcher_handler.cljs#L44-L56)).
|
||||
|
||||
Maintainer `cnrpman` fasst es zusammen: *„Basically it writes to `bak` when there's a dangerous file
|
||||
operation… **It's a safe net.**"* ([Issue #3370](https://github.com/logseq/logseq/issues/3370#issuecomment-1556840114)).
|
||||
|
||||
**Das ist keine Historie, sondern ein Fangnetz** — und genau das macht es für DioNotes interessant: Der
|
||||
billigste denkbare Nutzen (Datenverlust bei Konflikten verhindern) wird mit dem billigsten denkbaren
|
||||
Mechanismus erkauft, ohne dass irgendetwas am Schreibverfahren hängt.
|
||||
|
||||
**Aufräumen — es gibt eine Grenze, sie greift nur zu lokal** (**belegt**,
|
||||
[`backup_file.cljs#L27-L34`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/backup_file.cljs#L27-L34)):
|
||||
|
||||
```clojure
|
||||
(defn- truncate-old-versioned-files!
|
||||
"reserve the latest 6 version files"
|
||||
[dir]
|
||||
... (drop 6 (reverse (sort files))) ... removeSync ...)
|
||||
```
|
||||
|
||||
Warum der Ordner trotzdem wächst (**Ableitung aus dem Code**, deckt sich mit den Nutzerberichten):
|
||||
|
||||
- Die Grenze gilt **pro Datei-Unterordner**; die **Zahl der Unterordner ist unbegrenzt**.
|
||||
- Aufgeräumt wird **nur beim Schreiben eines neuen Backups in genau diesen Ordner**. Kein Startup-Sweep,
|
||||
kein Hintergrundjob. Ordner von Dateien, die nie wieder konfligieren, bleiben ewig stehen.
|
||||
- Sortiert wird rein lexikografisch — funktioniert nur, weil ISO-8601 lexikografisch = chronologisch ist.
|
||||
|
||||
Die Retention-Zahl wanderte über die Jahre: nie getrunct (≤ 0.6.1) → 10
|
||||
([PR #4421](https://github.com/logseq/logseq/pull/4421), Commit-Text: *„Files in the logseq/bak folder are
|
||||
never truncated"*) → 3 → 6. Gründer `tiensonqin` empfiehlt in
|
||||
[Issue #4819](https://github.com/logseq/logseq/issues/4819): *„**I suggest to cleanup backups using some
|
||||
cron scripts.**"* Es gibt **keine offizielle Doku** zum bak-Ordner und **keinen Off-Schalter**
|
||||
([Forum, Aug 2025](https://discuss.logseq.com/t/add-option-to-disable-backups-logseq-bak-folder/33107),
|
||||
unbeantwortet).
|
||||
|
||||
**Korrektur einer verbreiteten Annahme:** Beschwerden über *Wachstum* gibt es reichlich
|
||||
([#4819](https://github.com/logseq/logseq/issues/4819), [#3588](https://github.com/logseq/logseq/issues/3588)
|
||||
„600 or more backup files", [#3370](https://github.com/logseq/logseq/issues/3370) offen seit 2021,
|
||||
[Discussion #8624](https://github.com/logseq/logseq/discussions/8624) seit drei Jahren unbeantwortet). Aber
|
||||
**keine Primärquelle nennt eine konkrete GB-Zahl.** Belegt ist unbegrenztes Ordner-Wachstum, nicht
|
||||
unbegrenztes Größenwachstum.
|
||||
|
||||
**Weitere belegte Details:**
|
||||
|
||||
- **Nicht atomar geschrieben:** `fs/writeFileSync path content`, mit vorherigem `chmodSync` auf `644`, falls
|
||||
die Datei nicht schreibbar ist
|
||||
([`handler.cljs#L117-L126`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/handler.cljs#L117-L126)).
|
||||
- **Watcher: chokidar**, `awaitWriteFinish true`
|
||||
([`fs_watcher.cljs`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/fs_watcher.cljs)).
|
||||
- **Konflikt:** Vergleich **vor** dem Schreiben; bei Abweichung wird **nicht geschrieben**, sondern ein Modal
|
||||
gezeigt mit zwei **editierbaren** Textareas („On disk:" / „In Logseq:"), je einem Button „Select this" —
|
||||
kein Merge, kein Abbrechen
|
||||
([`components/diff.cljs#L27-L90`](https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/components/diff.cljs#L27-L90)).
|
||||
- **Undo überlebt den Neustart nicht.** Der Stack ist ein reines In-Memory-Atom über
|
||||
DataScript-Datoms — `(def ^:private undo-redo-states (atom {}))`
|
||||
([`undo_redo.cljs#L16-L28`](https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/modules/editor/undo_redo.cljs#L16-L28)).
|
||||
Die offizielle Doku formuliert es vorsichtig: *„during their editing sessions"*
|
||||
([docs: Undo and Redo](https://github.com/logseq/docs/blob/master/pages/Undo%20and%20Redo.md)).
|
||||
- **Es gibt keine UI, die bak-Versionen anzeigt oder wiederherstellt** — nur „Open page backups directory",
|
||||
das den Explorer öffnet. Der Menüpunkt „View page history" ist dagegen **Git**-basiert
|
||||
(`shell/get-file-latest-git-log`) und setzt ein eigenes Git-Setup des Nutzers voraus
|
||||
([`page_menu.cljs#L93-L159`](https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/components/page_menu.cljs#L93-L159)).
|
||||
|
||||
**Die Lehre:** Selbst das Werkzeug, das im Workspace sichert, hat sich gegen „bei jedem Speichern" und für
|
||||
„nur wenn es brenzlig wird" entschieden — und hat trotzdem ein Aufräumproblem, weil die Grenze pro Datei
|
||||
statt global gilt. Beides sind Entwurfsfehler, die man **beim Nachrüsten** vermeiden kann, weil sie mit dem
|
||||
Schreibverfahren nichts zu tun haben.
|
||||
|
||||
### 2.4 Zettlr — gar keine Historie, und das ist die offizielle Position
|
||||
|
||||
**Belegt: Zettlr hat kein Snapshot-, History- oder Versions-Feature.** Die Doku delegiert das Thema
|
||||
ausdrücklich an den Nutzer
|
||||
(<https://docs.zettlr.com/en/first-time-users/workspaces.html>):
|
||||
|
||||
> „We strongly recommend that you regularly back up your workspaces. The simplest method is to use a cloud
|
||||
> storage provider (such as Dropbox, Google Drive, OneDrive, or iCloud)… you should also back up the files
|
||||
> onto an external storage device from time to time."
|
||||
|
||||
Vorhanden ist nur der System-Papierkorb via `shell.trashItem`
|
||||
([`safe-delete.ts`](https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/fsal/util/safe-delete.ts))
|
||||
und ein **überschreibendes** Autosave.
|
||||
|
||||
**Schreibverfahren — nicht atomar** (**belegt**,
|
||||
[`fsal/index.ts#L529-L533`](https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/fsal/index.ts#L529-L533)):
|
||||
|
||||
```ts
|
||||
public async writeTextFile (filePath: string, contents: string): Promise<void> {
|
||||
await this._cache.del(filePath)
|
||||
await fs.writeFile(filePath, contents, 'utf-8')
|
||||
}
|
||||
```
|
||||
|
||||
Drei Details des Save-Pfads, die #18 bis #20 direkt betreffen (**belegt**,
|
||||
[`documents/index.ts#L1527-L1603`](https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/documents/index.ts#L1527-L1603)):
|
||||
|
||||
- Der Descriptor wird **vor** dem Schreiben aktualisiert, mit explizitem Kommentar zur Race Condition
|
||||
„User ändert → Save startet → User ändert mehr → Save beendet und macht die Änderungen rückgängig".
|
||||
- **Self-Echo-Unterdrückung:** `this._ignoreChanges.push(filePath)` — der eigene Write wird beim
|
||||
Watcher-Event wieder herausgefiltert.
|
||||
- **BOM und Zeilenende werden rekonstruiert:** intern arbeitet Zettlr immer mit `\n`, beim Schreiben wird der
|
||||
ursprüngliche Zustand wiederhergestellt (`doc.descriptor.bom + content.split('\n').join(doc.descriptor.linefeed)`).
|
||||
|
||||
**Konfliktbehandlung, dreistufig** (**belegt**,
|
||||
[`documents/index.ts#L1070-L1130`](https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/documents/index.ts#L1070-L1130)) —
|
||||
für [#20](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/20) unmittelbar verwertbar:
|
||||
|
||||
1. **mtime-Filter**, bewusst `modtime <= ourModtime → return` statt Gleichheitsprüfung. Kommentar wörtlich:
|
||||
*„In response to issue #1621: We will not check for equal modtime but only for newer modtime to prevent
|
||||
sluggish cloud synchronization services (e.g. OneDrive and Box) from having text appear to 'jump'."*
|
||||
2. **Inhaltsvergleich:** `if (diskContents === doc.lastSavedContent) return` — fängt Cloud-Dienste ab, die
|
||||
nur Attribute anfassen.
|
||||
3. **Dialog** „File changed on disk" mit den Buttons **„Keep editor contents"** / **„Load changes from disk"**
|
||||
(kein Merge, kein Diff) und einer Checkbox „Always load changes from disk if there are no unsaved changes
|
||||
in the editor", die direkt die Einstellung `alwaysReloadFiles` schreibt.
|
||||
|
||||
**Zur Git-Frage: Es gibt kein offizielles Zettlr-Git-Plugin** (**belegt**). `Zettlr/Zettlr-Git-Plugin`
|
||||
liefert 404; die Organisation hat keine Plugin-Repos
|
||||
(<https://github.com/orgs/Zettlr/repositories>). Zettlr hat überhaupt **kein Plugin-System** — Maintainer
|
||||
`nathanlesage`: *„Writing a plugin system is incredibly difficult, and thus we should do that extremely
|
||||
limited."* ([Discussion #3108](https://github.com/Zettlr/Zettlr/discussions/3108)). Das einzige Git-Feature
|
||||
ist reine Erkennung: `dir.isGitRepository = (await fs.lstat(path.join(dir.path, '.git'))).isDirectory()`
|
||||
([`fsal-directory.ts#L103-L105`](https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/fsal/fsal-directory.ts#L103-L105)).
|
||||
[Issue #1050 „Integrate basic git functionality"](https://github.com/Zettlr/Zettlr/issues/1050) ist seit Juli
|
||||
2020 offen, Label „Priority: Low".
|
||||
|
||||
**Die Lehre:** Ein etablierter, ernstgemeinter Markdown-Editor kommt seit Jahren **ohne jede eingebaute
|
||||
Historie** aus und verweist stattdessen auf externe Werkzeuge. Das ist der empirische Beleg dafür, dass
|
||||
„Historie nicht bauen" eine tragfähige Produktentscheidung ist — nicht bloß ein Aufschub.
|
||||
|
||||
### 2.5 Syncthing — kein Editor, aber das beste Vorbild für Ablage und Aufräumen
|
||||
|
||||
Syncthing ist der einzige untersuchte Vertreter, der Versionen **neben** den Nutzdateien ablegt, und hat dafür
|
||||
die durchdachteste Aufräumstrategie. Alles hier **belegt** aus
|
||||
<https://docs.syncthing.net/users/versioning.html>:
|
||||
|
||||
- **Ablageort:** Standardmäßig der Ordner **`.stversions`** innerhalb des freigegebenen Verzeichnisses. Ein
|
||||
eigener Pfad ist konfigurierbar, aber die Doku warnt ausdrücklich: „please ensure that it's on the same
|
||||
partition or filesystem as the regular folder path."
|
||||
- **Namenskonvention:** zeitgestempelte Dateinamen.
|
||||
- **Vier Modi:** *Trash Can* (letzte Fassung, `cleanoutDays`), *Simple* (N Fassungen pro Datei mit
|
||||
Zeitstempel), *Staggered* (altersbasiertes Ausdünnen), *External* (delegiert an ein Kommando).
|
||||
- **Der Staggered-Versioner dünnt logarithmisch aus:**
|
||||
|
||||
| Alter | behalten wird |
|
||||
|---|---|
|
||||
| 1 Stunde | die älteste Version pro 30-Sekunden-Intervall |
|
||||
| 1 Tag | die älteste Version pro Stunde |
|
||||
| 30 Tage | die älteste Version pro Tag |
|
||||
| bis „Maximum Age" | die älteste Version pro Woche |
|
||||
|
||||
„Maximum Age" = 0 bedeutet: für immer behalten.
|
||||
|
||||
Das ist die Antwort auf die Aufräumfrage, falls DioNotes je Historie bekommt: **konstanter Overhead statt
|
||||
unbegrenztem Wachstum**, ohne Diff-Rekonstruktion, ohne Datenbank. Vier Zeilen Regel.
|
||||
|
||||
---
|
||||
|
||||
## 3. Was ein Append-Log konkret kosten würde
|
||||
|
||||
Der Vorschlag aus der Beratungsrunde: ein Append-Log neben jeder Notiz, aus dem die `.md` als lesbare Sicht
|
||||
materialisiert wird. Undo über Sitzungen, Historie und Konfliktauflösung fielen als Nebenprodukt an.
|
||||
|
||||
Der Vorschlag ist technisch seriös — das ist Event Sourcing, und es liefert tatsächlich alles drei. Die Frage
|
||||
ist nicht, ob er funktioniert, sondern was er kostet.
|
||||
|
||||
### 3.1 Größenwachstum — belegte Zahlen
|
||||
|
||||
Die maßgebliche Zahl stammt aus dem **offiziellen Benchmark-Repo von Kevin Jahns**, dem Yjs-Autor, das die
|
||||
Yjs-README selbst verlinkt (<https://github.com/dmonad/crdt-benchmarks>). Gemessen wird der bekannte
|
||||
B4-Editier-Trace: 182.315 Einfügungen, 77.463 Löschungen, **104.852 Zeichen im Endtext**.
|
||||
|
||||
| | Yjs 13.6.11 | Automerge 2.1.10 |
|
||||
|---|---|---|
|
||||
| **Dokumentgröße gemerged** (`docSize`) | 159.929 B ≈ **1,53× Rohtext** | 129.116 B ≈ **1,23× Rohtext** |
|
||||
| **Summe aller Einzel-Updates** (`updateSize`) | **7.306.126 B ≈ 70× Rohtext** | — |
|
||||
| Durchschnittliche Update-Größe | 29 B | — |
|
||||
| Parse-Zeit | 39 ms | 1.805 ms |
|
||||
|
||||
**Das ist die eigentliche Kostenaussage, und sie ist zweigeteilt** (**belegt**):
|
||||
|
||||
- **Ein *zusammengefasstes* CRDT-Dokument ist erstaunlich billig** — 1,2 bis 1,5× des Rohtextes, mit voller
|
||||
Historie. Das ist deutlich günstiger, als man intuitiv erwartet.
|
||||
- **Ein *ungekürzter Append-Log* — also genau das, was der Vorschlag beschreibt — kostet rund 70× den
|
||||
Rohtext.** Der Unterschied zwischen 1,5× und 70× ist die Compaction. Ein Log, der nur angehängt und nie
|
||||
zusammengefasst wird, liegt beim oberen Wert.
|
||||
|
||||
Bei der Skalierung B4×100 (10,5 Mio. Zeichen) bleibt die Dokumentgröße bei 1,53×, der **Arbeitsspeicher**
|
||||
steigt aber auf 327,1 MB — etwa 31× des Textes (**belegt**, dieselbe Quelle).
|
||||
|
||||
Die offizielle Yjs-FAQ ordnet das ein (<https://docs.yjs.dev/api/faq>): „**Size is very rarely a practical
|
||||
problem** as long as you deal with human-entered text input." Das ist ein fairer Einwand gegen eine reine
|
||||
Größen-Argumentation — und der Grund, warum der Ausschlussgrund in 3.4 steht und nicht hier.
|
||||
|
||||
**Was daraus für DioNotes folgt** (**Ableitung**):
|
||||
|
||||
- **Eine Sidecar-Datei pro Notiz.** Ein Workspace mit 500 Notizen enthält 1.000 Dateien statt 500. Der
|
||||
Notiz-Baum müsste die Hälfte davon ausblenden; jedes Backup-, Sync- und Suchwerkzeug des Nutzers sieht sie.
|
||||
- **Autosave koppelt an den Speicherplatz.** Die Auslöser-Entscheidung aus
|
||||
[#18](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/18) würde zur
|
||||
Speicherplatz-Entscheidung: „Autosave nach Tipppause" erzeugt Größenordnungen mehr Log-Einträge als
|
||||
„manuell mit Strg+S". Diese Kopplung existiert bei keiner anderen Option aus Abschnitt 4.
|
||||
|
||||
### 3.2 Aufräumen: bei CRDT-Logs nicht vorgesehen
|
||||
|
||||
Der wichtigste Befund dieses Abschnitts, und er ist aus Maintainer-Mund belegt.
|
||||
|
||||
**Automerge** — Peter van Hardenberg (Maintainer) in
|
||||
[Issue #799](https://github.com/automerge/automerge/issues/799), 24.12.2023, wörtlich:
|
||||
|
||||
> „'Compact' could mean a few different things here — one is whether you can reduce the on-disk size of an
|
||||
> Automerge document. **This happens automatically when you call `save()`.** It quite literally compacts the
|
||||
> history into a very efficient columnar representation.
|
||||
>
|
||||
> **Discarding the history is not currently possible without replacing the document: if you don't have the
|
||||
> same history, you can't merge the same changes.** That's because we use a git-like commit hash for each
|
||||
> change to ensure integrity."
|
||||
|
||||
„Shallow clone" und „cherry pick" nennt er als nützlich, verwandt und „**not yet implemented**"; nachgefasst
|
||||
am 23.09.2024: „I don't know *when* it will happen." Der Automerge-2.0-Blogpost kündigt dasselbe schon seit
|
||||
Januar 2023 an (<https://automerge.org/blog/automerge-2/>, Abschnitt „History Management"):
|
||||
|
||||
> „Today the best way to remove something from an Automerge document's history is to **recreate the document
|
||||
> from scratch** or to reset to a time before that change went in."
|
||||
|
||||
Orion Henry (Automerge-Core) in [Issue #348](https://github.com/automerge/automerge/issues/348), 25.04.2022:
|
||||
„the solutions really are to either have the nodes coordinate **a total document reset every so often —
|
||||
throwing out the old document and starting fresh**". Issue #348 wurde am 21.08.2026 geschlossen mit dem
|
||||
Hinweis auf `hexane`, den neuen Storage-Engine — der laut offiziellem Blogpost
|
||||
(<https://automerge.org/blog/2026-july/>) „around 2–9× faster" ist, also **Kompression und Performance
|
||||
liefert, keine History-Truncation**. Die Schließung bedeutet nicht, dass das Feature existiert.
|
||||
|
||||
**Yjs** — die README nennt den Grund formal
|
||||
(<https://github.com/yjs/yjs/blob/main/README.md>, Abschnitt „Yjs CRDT Algorithm"):
|
||||
|
||||
> „CRDTs that are suitable for shared text editing suffer from the fact that they **only grow in size**. […]
|
||||
> **We can't garbage collect deleted structs (tombstones) while ensuring a unique order of the structs.**"
|
||||
|
||||
Im Quellcode bestätigt: `Item.gc()` ersetzt beim normalen Löschen nur den `content` durch
|
||||
`ContentDeleted(len)` — **das komplette Item-Struct mit `id{client,clock}`, `origin`, `rightOrigin`, `parent`
|
||||
und der Reihenfolge-Verlinkung bleibt stehen**
|
||||
(<https://github.com/yjs/yjs/blob/main/src/structs/Item.js>). Und das Zusammenfassen von Updates hilft nicht:
|
||||
„**Note that this feature only merges document updates and doesn't garbage-collect deleted content**"
|
||||
(<https://docs.yjs.dev/api/document-updates>). Wer Snapshots will, muss GC ganz abschalten — der Code wirft
|
||||
sonst: `if (originDoc.gc) { throw new Error('Garbage-collection must be disabled in \`originDoc\`!') }`
|
||||
(<https://github.com/yjs/yjs/blob/main/src/utils/Snapshot.js>).
|
||||
|
||||
**Das Gegenargument, das fairerweise dazugehört.** Das offizielle Automerge-Cookbook
|
||||
(<https://automerge.org/docs/cookbook/modeling-data/>) schreibt:
|
||||
|
||||
> „Martin Kleppmann did some experiments with a benchmark document to see how much space would be saved by
|
||||
> discarding history […] **The savings are not all that great, which is why we haven't prioritised history
|
||||
> truncation so far.**"
|
||||
|
||||
Das ist ernst zu nehmen — es gilt für Kleppmanns Messfall: **ein großes, weitgehend linear getipptes
|
||||
Dokument mit wenigen Actor-IDs.** Eine Notiz-App hat das umgekehrte Profil: **viele kleine Dokumente, die
|
||||
über Jahre in vielen kurzen Sitzungen angefasst werden**, also viele Actor-IDs pro Dokument. Ob die Ökonomie
|
||||
dort kippt, ist für dieses Dokument **nicht belegt** — die 70×-Zahl aus 3.1 legt es nahe, ist aber am
|
||||
B4-Trace gemessen, nicht am Notiz-Profil.
|
||||
|
||||
**Fazit dieses Abschnitts:** Aufräumen heißt bei einem CRDT-Log entweder gar nichts (Yjs behält Tombstones
|
||||
konstruktionsbedingt) oder „Dokument wegwerfen und neu anfangen" (Automerge). Genau denselben Kompromiss
|
||||
fährt Joplin mit dem Rebase der ältesten überlebenden Revision (2.2) — nur innerhalb einer Datenbank, wo er
|
||||
billig ist. Neben jeder Notiz im Dateisystem ist er es nicht.
|
||||
|
||||
### 3.3 Sidecar-Dateien in Sync-Ordnern — belegte Risiken
|
||||
|
||||
Ob die bloße Verdopplung der Dateizahl schadet, ist **nicht belegt**; die harten Limits sprechen sogar
|
||||
dagegen (Dropbox: „Users shouldn't sync more than **300.000 files**" und „**1.000.000 write operations per
|
||||
namespace in a 24 hour period**", <https://help.dropbox.com/plans/large-deployments>; OneDrive: „no more than
|
||||
a total of **300.000 items**",
|
||||
<https://support.microsoft.com/en-us/office/restrictions-and-limitations-in-onedrive-and-sharepoint-64883a5d-228e-48f5-b3d2-eb39e07630fa>).
|
||||
Bei typischen Workspace-Größen ist das unkritisch.
|
||||
|
||||
**Belegt sind dagegen zwei andere Fallstricke**, und beide treffen ein Sidecar-Design härter als eine
|
||||
einzelne Datei:
|
||||
|
||||
- **Platzhalter-Dateien werden als gelöscht interpretiert.** Obsidians offizielle Sync-FAQ
|
||||
(<https://help.obsidian.md/sync/faq>): „Cloud storage services like OneDrive and Dropbox offer 'Files
|
||||
on-demand' or 'Online-only files' features […] However, since these files are not always available locally,
|
||||
**Obsidian Sync will interpret them as deleted, leading to their removal from your remote vault.**"
|
||||
Ergänzend (<https://help.obsidian.md/sync-notes>): „iCloud Drive on Windows may lead to **file duplication
|
||||
or corruption**." Und die architektonische Begründung: „**Obsidian requires access to the entire vault for
|
||||
its features** […] This makes it difficult for some services to function reliably with Obsidian."
|
||||
- **Das bloße Nachsehen, ob eine Datei existiert, kann die App einfrieren.** Apples Technote TN3150
|
||||
(<https://developer.apple.com/documentation/technotes/tn3150-getting-ready-for-data-less-files>):
|
||||
„Materializing a dataless file can take time […] **the app may become unresponsive.** If an app's main
|
||||
queue remains unresponsive for a long time, **the system may terminate the app and trigger a watchdog
|
||||
crash.**" Als auslösende Aktionen nennt Apple ausdrücklich „**enumerating a directory's contents and
|
||||
checking whether a file exists**", und: „`stat` and `getattrlist` both trigger the materialization of any
|
||||
intermediate folders." Für jede Notiz die zugehörige Log-Datei zu suchen oder zu statten ist genau dieses
|
||||
Muster. (Die Technote gilt für Apple-Plattformen; DioNotes ist Windows-only, aber der Mechanismus —
|
||||
Platzhalter-Dateien, die beim Zugriff nachgeladen werden — ist bei OneDrive Files On-Demand derselbe.
|
||||
**Übertragung: Vermutung.**)
|
||||
|
||||
Apples eigene Empfehlung für mehrteilige Dokumente ist bezeichnend: **File Packages statt nebeneinander\-
|
||||
liegender Dateien** („If your document data format consists of multiple distinct pieces, use a file
|
||||
package", <https://developer.apple.com/library/archive/documentation/General/Conceptual/iCloudDesignGuide/Chapters/DesigningForDocumentsIniCloud.html>).
|
||||
Für DioNotes untauglich — dann wäre die `.md` keine eigenständige Datei mehr, und genau das ist das
|
||||
Versprechen.
|
||||
|
||||
> **Nicht als Beleg verwendbar** (gegengeprüft und verworfen): Zotereos Warnung „extremely likely to corrupt
|
||||
> your database" betrifft SQLite und File Locking, nicht Sidecar-Strukturen. git-annex' Warnung gegen
|
||||
> Dropbox-Ordner beruht auf Case-Insensitivity und Symlink-Handling. Für DVC gibt es keine offizielle
|
||||
> Warnung, nur Community-Threads.
|
||||
|
||||
### 3.4 Das Zwei-Wahrheiten-Problem — der eigentliche Einwand
|
||||
|
||||
Dieser Punkt braucht keine externe Quelle, er folgt aus den Rahmenbedingungen des Projekts.
|
||||
|
||||
DioNotes hat **keinen Filewatcher** ([#16](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/16),
|
||||
Rahmenentscheidung) und rechnet **ausdrücklich damit, dass Dropbox, Syncthing oder Git die Dateien ändern,
|
||||
während die App läuft** ([#20](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/20)).
|
||||
|
||||
Damit gilt für einen Append-Log:
|
||||
|
||||
- Ändert jemand die `.md` von außen, weiß der Log nichts davon. Der Log sagt „Inhalt X", die Datei sagt
|
||||
„Inhalt Y". **Zwei Wahrheiten.**
|
||||
- Beim nächsten Materialisieren gewinnt der Log — und **überschreibt die externe Änderung stillschweigend.**
|
||||
Das ist kein Randfall, sondern der Normalfall bei jedem Sync-Konflikt.
|
||||
- Der Ausweg wäre, die externe Änderung als neuen Eintrag in den Log einzuspeisen (Diff gegen den zuletzt
|
||||
materialisierten Stand). Das ist machbar — aber es setzt genau die Konflikterkennung voraus, die #20 erst
|
||||
noch entscheiden muss, und macht sie von einer Kann- zu einer **Muss**-Anforderung.
|
||||
|
||||
Die entscheidende Beobachtung: **Beim „Datei ist die Wahrheit"-Modell gibt es dieses Problem nicht.** Eine
|
||||
Sicherungskopie, die veraltet ist, ist eine weniger nützliche Sicherungskopie — kein inkonsistenter Zustand.
|
||||
Die Datei ist immer richtig, weil sie per Definition die Wahrheit ist. Der Append-Log führt eine
|
||||
Korrektheitslücke ein, die das schlichte Verfahren gar nicht erst hat.
|
||||
|
||||
### 3.5 Bewertung am Versprechen
|
||||
|
||||
Der Bewertungsmaßstab aus dem Projektkontext: „Der Nutzer kann das Werkzeug jederzeit verlassen, seine
|
||||
Dateien sind mit jedem Editor lesbar."
|
||||
|
||||
Ein Append-Log, aus dem die `.md` materialisiert wird, degradiert die `.md` zur **Ausgabe**. Formal bleibt sie
|
||||
lesbar — praktisch ist sie es nicht mehr im Sinne des Versprechens, denn:
|
||||
|
||||
- Eine Änderung an ihr ist **wirkungslos oder wird verworfen**, sobald DioNotes das nächste Mal
|
||||
materialisiert. Der Nutzer *darf* seine Dateien dann nicht mehr mit einem anderen Editor bearbeiten,
|
||||
sondern nur noch lesen.
|
||||
- Das Werkzeug zu verlassen, hieße die Historie zurückzulassen — sie steckt im Log, nicht in der Datei.
|
||||
|
||||
Der Unterschied zu einer Sicherungskopie ist damit kein gradueller: Eine Sicherungskopie ist **zusätzliche**
|
||||
Information neben der Wahrheit. Ein Log ist **die** Wahrheit, und die Datei wird zur Kopie. Das ist genau die
|
||||
Umkehrung, die das Versprechen bricht.
|
||||
|
||||
---
|
||||
|
||||
## 4. Die billigeren Mittelwege
|
||||
|
||||
Zur Einordnung: Alle drei sind **Aufsätze auf ein unverändertes Schreibverfahren**. Keiner verlangt, dass
|
||||
#19 heute irgendetwas anders entscheidet. Sie sind hier aufgeführt, damit die Größenordnung des „später"
|
||||
bekannt ist — nicht als Empfehlung, heute etwas davon zu bauen.
|
||||
|
||||
### 4.1 Eine einzelne Sicherungskopie der Vorfassung
|
||||
|
||||
Der billigste Weg: Vor dem Umbenennen die Zieldatei nach `<name>.md.bak` (oder in `.dionotes/bak/`) kopieren.
|
||||
Eine Fassung, überschrieben bei jedem Speichern.
|
||||
|
||||
- **Kosten:** eine Datei pro je gespeicherter Notiz, Größe = Größe der Notiz. Bei 500 Notizen à 4 KB also
|
||||
~2 MB. Kein Wachstum über die Zeit, weil jede Sicherung die vorige ersetzt. **Aufräumen entfällt.**
|
||||
- **Nutzen:** deckt genau einen Fall ab — „ich habe gerade etwas kaputtgespeichert". Kein Undo über
|
||||
Sitzungen, keine Historie.
|
||||
- **Präzedenzfall:** Logseq macht im Kern nichts anderes, nur mit sechs statt einer Fassung und nur bei
|
||||
Konflikt (2.3).
|
||||
- **Fallstrick:** Wenn die Sicherung **neben** der Notiz liegt, sieht der Nutzer sie im Ordner und im Sync —
|
||||
und der Notiz-Baum müsste sie ausblenden, ohne die Punkt-Regel aufzuweichen. In `.dionotes/` liegt sie
|
||||
außer Sichtweite, wird aber beim Löschen einer Notiz zur Waise. Beides lösbar, beides eine Entscheidung
|
||||
für später.
|
||||
|
||||
### 4.2 Zeitstempel-Ordner mit logarithmischer Ausdünnung (das Syncthing-Modell)
|
||||
|
||||
Ein Ordner `.dionotes/versions/<relativer-pfad>/<zeitstempel>.md`, ausgedünnt nach dem Muster aus 2.5.
|
||||
|
||||
- **Kosten:** Volltext-Kopien, aber durch die Ausdünnung mit **konstantem Overhead statt unbegrenztem
|
||||
Wachstum**. Bei den Syncthing-Buckets landet man pro Notiz nach einem Jahr bei grob 50–100 Fassungen
|
||||
(**Abschätzung, nicht belegt**) — für eine 4-KB-Notiz also ein paar hundert Kilobyte.
|
||||
- **Nutzen:** echte Historie mit Zeitpunkten, Undo über Sitzungen, kein Diff-Algorithmus nötig.
|
||||
- **Belegtes Vorbild in Produktion:** Syncthings `.stversions` mit vier Modi und den Ausdünn-Buckets
|
||||
(2.5, <https://docs.syncthing.net/users/versioning.html>).
|
||||
- **Warum das die beste Nachrüst-Option wäre:** Es braucht keine Datenbank, kein Diff-Format, keine
|
||||
Kettenrekonstruktion wie bei Joplin (2.2) — und es vermeidet Logseqs Aufräumfehler, weil die Ausdünnung
|
||||
altersbasiert statt anzahlbasiert-pro-Datei ist.
|
||||
|
||||
### 4.3 Git-Repository im Workspace
|
||||
|
||||
Die Variante, die manche Nutzer heute schon von Hand fahren — und die Logseqs „View page history" bereits
|
||||
voraussetzt (2.3).
|
||||
|
||||
**Belegte Kosten:**
|
||||
|
||||
- Pro Commit entstehen **mindestens drei lose Objekte** (Blob + Root-Tree + Commit), pro Verzeichnisebene ein
|
||||
weiteres Tree-Objekt. Format: zlib-komprimierter Header + Inhalt unter
|
||||
`.git/objects/<2>/<38>` (<https://git-scm.com/book/en/v2/Git-Internals-Git-Objects>).
|
||||
- **Lose Objekte enthalten den vollen Blob, keine Deltas** — Delta-Kompression gibt es erst beim Packen. Eine
|
||||
Ein-Zeichen-Änderung an einer 5-KB-Notiz erzeugt einen kompletten neuen komprimierten Blob (**Ableitung**
|
||||
aus derselben Quelle).
|
||||
- `gc.auto` = **6700** lose Objekte, `gc.autoPackLimit` = **50** Packs, `gc.pruneExpire` = **2.weeks.ago**,
|
||||
`gc.autoDetach` = **true** (<https://git-scm.com/docs/git-config>, <https://git-scm.com/docs/git-gc>).
|
||||
- Sobald Housekeeping ausgelöst wird, laufen laut Doku **alle** Housekeeping-Aufgaben mit (rerere, worktrees,
|
||||
reflog).
|
||||
|
||||
**Ableitung für DioNotes:** 6700 ÷ ~3 Objekte je Commit ≈ **~2.200 Commits bis zum ersten automatischen
|
||||
Repack**. Bei „ein Commit pro Speichern" mit Autosave ist das eine Frage von Wochen, nicht von Jahren. Der
|
||||
Repack läuft dann im Hintergrund, schreibt große `.pack`-Dateien und löscht tausende lose Dateien auf einen
|
||||
Schlag — mitten in einem Ordner, den womöglich Dropbox oder Syncthing beobachtet. Für den Sync-Client sieht
|
||||
das aus wie ein Änderungssturm (**Ableitung, mittlere Konfidenz**). `.git/` gehörte damit zwingend aus dem
|
||||
Sync ausgenommen.
|
||||
|
||||
**Die Rust-Seite** (**belegt**, crates.io/docs.rs):
|
||||
|
||||
| Crate | Version | Downloads gesamt | C-Toolchain nötig? |
|
||||
|---|---|---|---|
|
||||
| `git2` (libgit2-Bindings) | 0.21.0 | ~110 Mio. | **Ja** — `libgit2-sys` kompiliert ~180k Zeilen C via `cc` (kein CMake) |
|
||||
| `gix` / gitoxide | 0.87.1 | ~44 Mio. | **Nein** — Feature-Doku wörtlich: „No C toolchain is involved." |
|
||||
|
||||
`gix` beschreibt sich als „pure Rust implementation of Git", testet Windows in CI („failures do prevent
|
||||
releases") und deckt commit, log, status, diff, reset ab. Der Haken ist der Reifegrad: Der Haupt-Einstiegs\-
|
||||
punkt `gix` steht laut eigenem Stability-Tier-Schema noch auf **„Initial Development"**, entsprechend
|
||||
API-Churn bei jedem Minor. Unter Windows nutzt libgit2 WinHTTP, OpenSSL ist für lokale Repo-Operationen nicht
|
||||
nötig (**Ableitung** aus der Windows-Verlinkung in `build.rs`).
|
||||
|
||||
**Bewertung:** Git ist der einzige Weg, der Historie *und* Diff *und* Branching liefert — und der einzige,
|
||||
der eine schwergewichtige Dependency plus unbegrenztes Wachstum plus periodisch teures Repacking mitbringt.
|
||||
Für „Undo über Sitzungen" ist es mit Abstand die teuerste Antwort. Bemerkenswert ist, was die untersuchten
|
||||
Editoren daraus machen: Zettlr empfiehlt Git **nicht** (2.4), Logseq benutzt es nur lesend und setzt voraus,
|
||||
dass der Nutzer es selbst eingerichtet hat (2.3). **Niemand richtet dem Nutzer ein Repo ein.**
|
||||
|
||||
### 4.4 Kostenvergleich
|
||||
|
||||
| Option | Zusätzliche Dateien | Wachstum | Aufräumen | Dependency | Bricht das Versprechen? |
|
||||
|---|---|---|---|---|---|
|
||||
| Nichts tun (heute) | 0 | — | — | keine | nein |
|
||||
| Eine Sicherungskopie (4.1) | 1 pro gespeicherter Notiz | keins | entfällt | keine | nein |
|
||||
| Zeitstempel-Ordner (4.2) | N pro Notiz, ausgedünnt | beschränkt | Ausdünn-Regel, ~1 Funktion | keine | nein |
|
||||
| Git im Workspace (4.3) | `.git/`, ≥3 Objekte pro Commit | unbeschränkt | `git gc`, teuer | `gix` oder `git2` | nein |
|
||||
| Append-Log (Abschnitt 3) | 1 Log pro Notiz | **≈ 70× Rohtext** ungekürzt, ≈ 1,2–1,5× gemerged | **von den Bibliotheken nicht vorgesehen** (Automerge: Dokument neu anlegen; Yjs: Tombstones bleiben) | CRDT-/Log-Bibliothek | **ja** — die `.md` wird zur Kopie |
|
||||
|
||||
---
|
||||
|
||||
## 5. Die eigentliche Frage: Schließt temp+rename eine spätere Historie aus?
|
||||
|
||||
**Nein.** Die Begründung in vier Schritten.
|
||||
|
||||
### 5.1 Das Schreibverfahren ist für Historie günstig, nicht hinderlich
|
||||
|
||||
Wie in Abschnitt 1 hergeleitet: Historie braucht Auslöser und Vorfassung. Beim temp+rename-Verfahren ist die
|
||||
Vorfassung im Moment vor dem Umbenennen noch vollständig auf der Platte. Der Einbau einer Historie wäre eine
|
||||
Einfügung von wenigen Zeilen **an einer Stelle**, an der man ohnehin schon steht:
|
||||
|
||||
```
|
||||
schreibe_notiz(pfad, inhalt):
|
||||
prüfe Konflikt (aus #20)
|
||||
── hier ließe sich später die Vorfassung wegsichern ──
|
||||
schreibe inhalt nach pfad.tmp
|
||||
sync
|
||||
benenne pfad.tmp nach pfad um
|
||||
```
|
||||
|
||||
Das Gegenteil gilt für den naiven Weg: `fs::write(pfad, inhalt)` zerstört die Vorfassung, bevor irgendjemand
|
||||
sie sichern könnte. Wer dort Historie nachrüstet, muss die Datei zusätzlich vorher lesen. Das ist immer noch
|
||||
billig — aber es ist der **teurere** der beiden Wege, und er ist zugleich der, den #19 aus Gründen der
|
||||
Absturzsicherheit ohnehin ausschließen will.
|
||||
|
||||
**Damit ist der Einwand in seiner konkreten Form widerlegt**: Die Entscheidung, die #19 zu treffen hat, macht
|
||||
Historie nicht teurer. Sie macht sie billiger.
|
||||
|
||||
### 5.2 Was Historie *tatsächlich* teuer machen würde
|
||||
|
||||
Damit das Ergebnis belastbar ist, hier die Gegenprobe — die Entscheidungen, die den Einwand berechtigt machen
|
||||
würden. Keine davon ist die Wahl zwischen `rename` und `write`:
|
||||
|
||||
| Falle | Warum sie teuer wird | Kosten sie zu vermeiden |
|
||||
|---|---|---|
|
||||
| **Mehrere Schreibstellen.** Autosave schreibt hier, „Notiz verlassen" dort, „Fenster schließen" an dritter Stelle. | Historie muss an jeder Stelle nachgezogen werden, und jede vergessene Stelle ist ein Loch in der Historie, das man erst durch einen Bug bemerkt. | **Eine** Funktion, die schreibt. Alle Auslöser aus [#18](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/18) rufen dieselbe. Kostet heute nichts. |
|
||||
| **Kein Notiz-Begriff, nur Pfade.** | Historie, die am Pfad hängt, reißt beim Umbenennen ab ([#23](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/23)). Obsidian hat genau dieses Problem, dokumentiert („snapshots are stored with the absolute path to the note"). | Entweder akzeptieren (Obsidian tut es) oder später eine stabile ID einführen. Beides nachrüstbar; die Kette bräche dann nur für Umbenennungen *vor* der Einführung. |
|
||||
| **Umbenennen als Löschen+Neuanlegen implementiert.** | Zerreißt jede Historie und erzeugt Waisen im Ablageort. | Umbenennen als Umbenennen implementieren. Ist in #23 ohnehin die naheliegende Wahl. |
|
||||
| **Der Log-Ansatz aus dem Vorschlag.** | Siehe Abschnitt 3 — er ist die einzige der hier betrachteten Optionen, die schwer umkehrbar ist. | Ihn nicht bauen. |
|
||||
|
||||
**Nur die erste Zeile ist eine echte Auflage an #19** — und sie kostet heute exakt nichts, weil es aktuell
|
||||
noch **überhaupt keine Schreibstelle im Code gibt** (verifiziert: keine Fundstelle für `fs::`/`write`/`File::`
|
||||
unter `src/`; der Editor nimmt Eingaben entgegen und verwirft sie, so beschrieben in
|
||||
[#16](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/16)).
|
||||
|
||||
### 5.3 Die Asymmetrie läuft andersherum
|
||||
|
||||
Der Vorschlag aus der Beratungsrunde begründet sich damit, dass die Schreib-Entscheidung schwer umkehrbar sei.
|
||||
Prüft man beide Richtungen, ergibt sich das Gegenteil:
|
||||
|
||||
| Richtung | Aufwand |
|
||||
|---|---|
|
||||
| „Datei ist die Wahrheit" → „Datei plus Sicherungen" | **Ergänzung.** Eine Funktion erweitern, ein Ordner, eine Aufräumregel. Bestehende Notizen funktionieren unverändert weiter, die Historie beginnt eben ab Einführung. Kein Datenmigrations-Schritt, kein Bruch für den Nutzer. |
|
||||
| „Log ist die Wahrheit" → „Datei ist die Wahrheit" | **Migration.** Jede Notiz aus dem Log materialisieren, die Logs entfernen oder verwaisen lassen, die gesamte gesammelte Historie wegwerfen oder in ein neues Format überführen — und den Nutzern erklären, warum ihre Historie weg ist. |
|
||||
|
||||
Wer sich die Option offenhalten will, hält sie also gerade dadurch offen, dass er den Log **nicht** baut. Der
|
||||
Log ist die Einbahnstraße, nicht das schlichte Schreiben.
|
||||
|
||||
Obsidian ist der empirische Beleg: Das Werkzeug wurde 2020 veröffentlicht, hat von Anfang an lose `.md`-Dateien
|
||||
geschrieben, und File recovery kam später als Core-Plugin obendrauf — vollständig außerhalb des Vault, ohne
|
||||
das Dateiformat oder das Schreibverfahren anzufassen (**belegt** durch den Aufbau des Plugins, s. 2.1;
|
||||
das genaue Erscheinungsdatum des Plugins ist **nicht belegt**).
|
||||
|
||||
### 5.4 Belastbarkeit dieser Antwort
|
||||
|
||||
Die Antwort steht auf zwei Beinen unterschiedlicher Härte:
|
||||
|
||||
- **Hart (belegt):** Obsidian betreibt eine produktive Historie für lose `.md`-Dateien als aufgesetzte
|
||||
Schicht, die vom Schreibverfahren des Kerns entkoppelt ist und außerhalb des Datenordners lebt. Ein
|
||||
Werkzeug mit derselben Ausgangslage wie DioNotes hat das Problem also nachweislich nachträglich gelöst.
|
||||
- **Weich (Argumentation, nicht durch Quelle belegt):** Dass genau *eine* Schreibstelle die einzige
|
||||
load-bearing Auflage ist, ist eine Schlussfolgerung aus der Zerlegung in Abschnitt 1, kein Zitat. Sie ist
|
||||
aber schwer zu bestreiten: Eine Schicht über N Aufrufstellen einzuziehen kostet N Änderungen, über einer
|
||||
Aufrufstelle eine.
|
||||
|
||||
**Fazit für #19: Der Einwand ist entkräftet. Die Schreib-Entscheidung darf allein nach den Kriterien aus #19
|
||||
fallen — Absturzsicherheit, Sichtbarkeit der temporären Datei, Dateirechte, Aufräumen. Historie legt ihr
|
||||
genau eine Auflage auf, und die ist heute gratis: eine einzige Funktion, durch die alles Schreiben läuft.**
|
||||
|
||||
---
|
||||
|
||||
## 6. Randbefunde für die Nachbartickets
|
||||
|
||||
Bei der Recherche fielen Belege an, die #22 nicht beantworten, aber #19 und #20 direkt betreffen. Sie stehen
|
||||
hier, damit sie nicht verlorengehen — **nicht** als Entscheidung.
|
||||
|
||||
**Für [#19](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/19) (Schreibverfahren):**
|
||||
|
||||
- **Weder Rust noch Microsoft dokumentieren `rename` als atomar.** Das Wort „atomic" kommt weder in
|
||||
<https://doc.rust-lang.org/std/fs/fn.rename.html> noch in der `MoveFileExW`-Doku vor. Rust garantiert nur
|
||||
„replacing the original file if `to` already exists" und nennt `MoveFileExW` mit Fallback auf
|
||||
`SetFileInformationByHandle`. Man verlässt sich auf undokumentiertes NTFS-Verhalten (**belegt durch
|
||||
Abwesenheit**).
|
||||
- **Microsoft empfiehlt für genau diesen Anwendungsfall `ReplaceFile`, nicht `MoveFileEx`.** Aus
|
||||
<https://learn.microsoft.com/en-us/windows/win32/fileio/deprecation-of-txf>, Abschnitt „Applications
|
||||
updating a single file with 'document-like' data": *„A common approach is to write the document to a new
|
||||
file, then replace the original file with the new one. **One method to do this is with the `ReplaceFile`
|
||||
API.**"*
|
||||
- **Temp+rename verliert Metadaten.** Die `ReplaceFileW`-Remarks führen als Vorteil auf, dass die Funktion
|
||||
*creation time, short file name, object identifier, DACLs, security resource attributes, encryption,
|
||||
compression* und *named streams* der Originaldatei erhält — was per Kontrast belegt, dass ein nacktes
|
||||
temp+rename genau diese verliert. Die **File ID ändert sich sogar bei `ReplaceFile`**. Kein untersuchtes
|
||||
Rust-Crate (`tempfile`, `atomicwrites`, `atomic-write-file`) nutzt `ReplaceFile`; `atomic-write-file`
|
||||
dokumentiert die Verluste immerhin explizit („No support for preserving timestamps, ACLs, extended
|
||||
attributes…").
|
||||
→ **Praktische Folge für #19:** Ein „erstellt am" gehört ins Frontmatter der `.md`, nicht ins Dateisystem.
|
||||
- **Das realistischste Fehlerbild ist `ERROR_SHARING_VIOLATION`**, wenn Defender oder ein Sync-Client die
|
||||
Zieldatei ohne `FILE_SHARE_DELETE` offen hält (<https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew>).
|
||||
`tempfile::NamedTempFile::persist` gibt die temporäre Datei im `PersistError` zurück — genau für den
|
||||
Retry-Fall gebaut. Ebenfalls belegt: `persist` synchronisiert weder Inhalt noch Verzeichnis.
|
||||
- **Die temporäre Datei muss im selben Verzeichnis liegen** — `fs::rename` scheitert über Volume-Grenzen
|
||||
(„This will not work if the new name is on a different mount point"), und Syncthing warnt bei seinem
|
||||
eigenen Versions-Ordner mit derselben Begründung.
|
||||
- **`fs-err` löst das Problem nicht** (es ist nur ein Drop-in-Ersatz für `std::fs` mit besseren
|
||||
Fehlermeldungen), ist aber die naheliegende Ergänzung, um überhaupt zu sehen, *welche* Datei die
|
||||
Sharing-Violation geworfen hat.
|
||||
|
||||
**Für [#20](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/20) (Zeitstempel-Konflikt):**
|
||||
|
||||
- **Zettlrs mtime-Regel ist bewusst `modtime <= ourModtime → ignorieren`, nicht Gleichheitsprüfung**, mit
|
||||
wörtlicher Begründung im Code: *„to prevent sluggish cloud synchronization services (e.g. OneDrive and Box)
|
||||
from having text appear to 'jump'"*. Danach folgt ein **Inhaltsvergleich**, weil Cloud-Dienste Dateien
|
||||
anfassen, ohne den Inhalt zu ändern. Ein reiner mtime-Vergleich fängt das nicht ab.
|
||||
- **Logseq vergleicht ebenfalls den Inhalt vor dem Schreiben** und schreibt bei Abweichung gar nicht.
|
||||
Beide untersuchten Open-Source-Editoren lösen den Konflikt über Inhalt, nicht über Zeitstempel allein.
|
||||
- **Beide brauchen einen Self-Echo-Filter**, damit der eigene Schreibvorgang nicht als externe Änderung
|
||||
zurückkommt (Zettlr: `_ignoreChanges`-Liste; Logseq: Inhaltsvergleich). Das ist erst relevant, falls je ein
|
||||
Filewatcher dazukommt — aber es erklärt, warum ein Watcher nicht „nur ein Watcher" ist.
|
||||
- **Dropbox nennt als dokumentierte Ursache Nr. 3 für Conflicted Copies wörtlich „applications with an
|
||||
auto-save feature"** (<https://help.dropbox.com/organize/conflicted-copy>). Das ist ein Argument für
|
||||
[#18](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/18), Autosave zu entprellen statt pro
|
||||
Tastendruck zu schreiben.
|
||||
- **Syncthings Konfliktnamen** sind `<name>.sync-conflict-<date>-<time>-<modifiedBy>.<ext>`, und diese
|
||||
Dateien werden „treated as normal files after they are created, so they are propagated between devices" —
|
||||
sie tauchen also im Notiz-Baum auf, wenn nichts sie ausfiltert.
|
||||
|
||||
---
|
||||
|
||||
## 7. Belegt vs. Vermutung — Übersicht
|
||||
|
||||
| Aussage | Status |
|
||||
|---|---|
|
||||
| Obsidian: Snapshots außerhalb des Vault, `%APPDATA%\Obsidian\`, Volltext, 5 Min / 7 Tage Default | **Belegt** (help.obsidian.md) |
|
||||
| Obsidian: Snapshots syncen nicht, absolute Pfade, brechen nach Vault-Umzug | **Belegt** (help.obsidian.md) |
|
||||
| Obsidian: Snapshots liegen konkret in IndexedDB/LevelDB | **Vermutung** — starkes Indiz über die Lockdown-Mode-Einschränkung, kein wörtlicher Beleg |
|
||||
| Obsidian: Serialisierungsformat der Snapshots | **Nicht belegbar** — Closed Source, nirgends dokumentiert |
|
||||
| Obsidian: Schreibverfahren des Kerns (atomar oder nicht) | **Nicht belegbar** — keine offizielle Aussage |
|
||||
| Obsidian: `Vault.process` ist „atomar" nur prozessintern, nicht auf FS-Ebene | **Lesart** — die Doku macht die Unterscheidung nicht explizit |
|
||||
| Joplin: SQLite statt `.md`, `revisions`-Tabelle, `diff-match-patch`, 10 Min / 90 Tage | **Belegt** (joplinapp.org + Quellcode) |
|
||||
| Joplin: Rebase der ältesten überlebenden Revision beim Aufräumen | **Belegt** (Quellcode-Kommentar + Implementierung) |
|
||||
| Joplin: konkrete Größe der Revisions-Tabelle in der Praxis | **Nicht belegbar** — keine offizielle Zahl |
|
||||
| Logseq: `logseq/bak/<datei>/<ISO>.md`, nur bei Konflikt/Schreibfehler/externer Änderung | **Belegt** (Quellcode 0.10.9) |
|
||||
| Logseq: „reserve the latest 6 version files", nur beim Schreiben in denselben Ordner | **Belegt** (Quellcode) |
|
||||
| Logseq: bak-Ordner werden GB-groß | **Nicht belegbar** — belegt ist unbegrenztes *Ordner*-Wachstum, keine Primärquelle nennt eine Größe |
|
||||
| Logseq: nicht-atomares `writeFileSync`, chokidar-Watcher, Diff-Modal bei Konflikt | **Belegt** (Quellcode) |
|
||||
| Logseq: Undo ist reines In-Memory-Atom, überlebt Neustart nicht | **Belegt** (Quellcode + Doku) |
|
||||
| Zettlr: keine eingebaute Historie, Doku verweist auf Cloud-Backup | **Belegt** (docs.zettlr.com) |
|
||||
| Zettlr: `fs.writeFile`, nicht atomar; mtime-Filter „nur neuer, nicht ungleich"; Konflikt-Dialog | **Belegt** (Quellcode master @ 86c6c56) |
|
||||
| Zettlr: kein offizielles Git-Plugin, kein Plugin-System | **Belegt** (404 + Maintainer-Aussage in Discussion #3108) |
|
||||
| Syncthing: `.stversions`, Zeitstempel-Namen, vier Modi, staggered-Buckets | **Belegt** (docs.syncthing.net) |
|
||||
| Git: `gc.auto` = 6700, `gc.autoPackLimit` = 50, ≥3 lose Objekte pro Commit | **Belegt** (git-scm.com) |
|
||||
| Git: „~2.200 Commits bis zum ersten Repack" | **Ableitung** aus den belegten Defaults |
|
||||
| `gix` braucht keinen C-Compiler, `git2` schon | **Belegt** (docs.rs Feature-Doku, `libgit2-sys/build.rs`) |
|
||||
| Ausgedünnte Zeitstempel-Historie ≈ 50–100 Fassungen pro Notiz nach einem Jahr | **Abschätzung**, nicht belegt |
|
||||
| „Historie ist eine Schicht über dem Schreiben" | **Belegt am Beispiel Obsidian**, im Übrigen **Argumentation** |
|
||||
| „Eine Schreibstelle ist die einzige echte Auflage" | **Argumentation**, nicht durch Quelle belegt — aber aus der Zerlegung in Abschnitt 1 zwingend |
|
||||
| Zwei-Wahrheiten-Problem des Append-Logs | **Argumentation** aus den Projekt-Rahmenbedingungen (kein Filewatcher, externer Sync erwartet) |
|
||||
| CRDT-Dokument **gemerged** ≈ 1,2–1,5× Rohtext; **Summe der Einzel-Updates ≈ 70× Rohtext** (B4-Trace) | **Belegt** (dmonad/crdt-benchmarks, Repo des Yjs-Autors) |
|
||||
| Automerge: „Discarding the history is not currently possible without replacing the document" | **Belegt** (Maintainer-Zitat, Issue #799) |
|
||||
| Automerge: shallow clone / history truncation seit 2023 angekündigt, bis heute nicht implementiert | **Belegt** (Blogpost + Issue-Verlauf + API-Oberfläche) |
|
||||
| Yjs: Tombstones sind konstruktionsbedingt nicht sammelbar, Update-Merge räumt nicht auf | **Belegt** (README, `Item.js`, docs.yjs.dev) |
|
||||
| Automerge-Cookbook: „The savings are not all that great" (Gegenargument) | **Belegt** — gilt für Kleppmanns Messfall (großes Dokument, wenige Actor-IDs) |
|
||||
| Ob die Ökonomie beim Notiz-Profil (viele kleine Dokumente, viele Sitzungen) kippt | **Nicht belegt** — die 70×-Zahl legt es nahe, ist aber am B4-Trace gemessen |
|
||||
| Obsidian Sync: „Files on-demand"-Platzhalter werden als gelöscht interpretiert | **Belegt** (help.obsidian.md) |
|
||||
| Apple TN3150: Existenzprüfung/Verzeichnis-Enumeration kann Materialisierung und Watchdog-Crash auslösen | **Belegt** — Übertragung auf OneDrive Files On-Demand unter Windows ist **Vermutung** |
|
||||
| Dass die bloße Verdopplung der Dateizahl in Sync-Ordnern schadet | **Nicht belegt** — die 300.000er-Limits sprechen eher dagegen |
|
||||
|
||||
---
|
||||
|
||||
## Quellenliste
|
||||
|
||||
**Obsidian**
|
||||
- File recovery (Core-Plugin): <https://obsidian.md/help/plugins/file-recovery>
|
||||
- How Obsidian stores data: <https://obsidian.md/help/data-storage>
|
||||
- Vault API Guide: <https://docs.obsidian.md/Plugins/Vault>
|
||||
- Vault TypeScript-Referenz: <https://docs.obsidian.md/Reference/TypeScript+API/Vault>
|
||||
- `DataAdapter.write`: <https://docs.obsidian.md/Reference/TypeScript+API/DataAdapter/write>
|
||||
- Sync Version history: <https://obsidian.md/help/sync/version-history>
|
||||
- Changelog 1.13.4 (Save-Race-Condition): <https://obsidian.md/changelog/2026-07-30-desktop-v1.13.4/>
|
||||
- Changelog 1.6.0 (externe Änderungen): <https://obsidian.md/changelog/2024-05-09-desktop-v1.6.0/>
|
||||
- Feature-Request „Snapshots im Vault" (*Sekundärquelle*): <https://forum.obsidian.md/t/file-recovery-store-file-snapshots-in-the-vault-folder-instead-of-the-obsidians-system-folder/116677>
|
||||
|
||||
**Joplin**
|
||||
- Note History: <https://joplinapp.org/help/apps/note_history/>
|
||||
- User profile / Datenablage: <https://joplinapp.org/help/dev/spec/user_profile/>
|
||||
- Import & Export: <https://joplinapp.org/help/apps/import_export/>
|
||||
- `Revision.ts`: <https://github.com/laurent22/joplin/blob/dev/packages/lib/models/Revision.ts>
|
||||
- `RevisionService.ts`: <https://github.com/laurent22/joplin/blob/dev/packages/lib/services/RevisionService.ts>
|
||||
- `builtInMetadata.ts` (Defaults): <https://github.com/laurent22/joplin/blob/dev/packages/lib/models/settings/builtInMetadata.ts>
|
||||
|
||||
**Logseq** (alle Codebelege gegen Tag `0.10.9`)
|
||||
- `backup_file.cljs` (bak-Ordner, Namensschema, Truncate): <https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/backup_file.cljs>
|
||||
- `handler.cljs` (Schreiben, `.recycle`, `backupDbFile`): <https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/handler.cljs>
|
||||
- `fs/node.cljs` (Vergleich vor dem Schreiben): <https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/fs/node.cljs>
|
||||
- `watcher_handler.cljs` (Backup bei externer Änderung): <https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/fs/watcher_handler.cljs>
|
||||
- `fs_watcher.cljs` (chokidar): <https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/fs_watcher.cljs>
|
||||
- `components/diff.cljs` (Konflikt-Modal): <https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/components/diff.cljs>
|
||||
- `undo_redo.cljs` (In-Memory-Undo): <https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/modules/editor/undo_redo.cljs>
|
||||
- `page_menu.cljs` („View page history" via `git log`): <https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/components/page_menu.cljs>
|
||||
- Doku „Undo and Redo": <https://github.com/logseq/docs/blob/master/pages/Undo%20and%20Redo.md>
|
||||
- Issue #3370 (Maintainer: „It's a safe net"): <https://github.com/logseq/logseq/issues/3370>
|
||||
- Issue #4819 (Gründer: „cleanup backups using some cron scripts"): <https://github.com/logseq/logseq/issues/4819>
|
||||
- PR #4421 (Einführung des Truncate): <https://github.com/logseq/logseq/pull/4421>
|
||||
- Forum: Wunsch nach Off-Schalter (*Sekundärquelle*): <https://discuss.logseq.com/t/add-option-to-disable-backups-logseq-bak-folder/33107>
|
||||
|
||||
**Zettlr** (Codebelege gegen `master` @ `86c6c56`)
|
||||
- Workspaces / Backup-Empfehlung: <https://docs.zettlr.com/en/first-time-users/workspaces.html>
|
||||
- Settings (Autosave, „Always load remote changes"): <https://docs.zettlr.com/en/reference/settings.html>
|
||||
- `fsal/index.ts` (`writeTextFile`): <https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/fsal/index.ts>
|
||||
- `documents/index.ts` (`saveFile`, `handleRemoteChange`): <https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/documents/index.ts>
|
||||
- `fsal-watchdog.ts` (chokidar): <https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/fsal/fsal-watchdog.ts>
|
||||
- `fsal-directory.ts` (`isGitRepository`): <https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/fsal/fsal-directory.ts>
|
||||
- Discussion #3108 (kein Plugin-System): <https://github.com/Zettlr/Zettlr/discussions/3108>
|
||||
- Issue #1050 (Git seit 2020 offen): <https://github.com/Zettlr/Zettlr/issues/1050>
|
||||
|
||||
**Append-Log / CRDT (Automerge, Yjs)**
|
||||
- Automerge Issue #799 (Maintainer pvh: „Discarding the history is not currently possible…"): <https://github.com/automerge/automerge/issues/799>
|
||||
- Automerge Issue #348 (Orion Henry: „a total document reset every so often"): <https://github.com/automerge/automerge/issues/348>
|
||||
- Automerge 2.0, Abschnitt „History Management": <https://automerge.org/blog/automerge-2/>
|
||||
- Automerge Cookbook, „Modeling Data → Performance" (Kleppmann: „The savings are not all that great"): <https://automerge.org/docs/cookbook/modeling-data/>
|
||||
- Automerge `hexane` Storage-Engine (Kompression, keine Truncation): <https://automerge.org/blog/2026-july/>
|
||||
- Yjs README, „Yjs CRDT Algorithm" (Tombstones): <https://github.com/yjs/yjs/blob/main/README.md>
|
||||
- Yjs `src/structs/Item.js` (`Item.gc()`): <https://github.com/yjs/yjs/blob/main/src/structs/Item.js>
|
||||
- Yjs `src/utils/Snapshot.js` (GC muss aus sein): <https://github.com/yjs/yjs/blob/main/src/utils/Snapshot.js>
|
||||
- Yjs Document Updates („doesn't garbage-collect deleted content"): <https://docs.yjs.dev/api/document-updates>
|
||||
- Yjs FAQ („Size is very rarely a practical problem"): <https://docs.yjs.dev/api/faq>
|
||||
- crdt-benchmarks (Kevin Jahns, Zahlen zu B4): <https://github.com/dmonad/crdt-benchmarks>
|
||||
|
||||
**Sidecar-Dateien in Sync-Ordnern**
|
||||
- Obsidian Sync FAQ („interpret them as deleted"): <https://help.obsidian.md/sync/faq>
|
||||
- Obsidian, Notizen zwischen Geräten synchronisieren: <https://help.obsidian.md/sync-notes>
|
||||
- Apple TN3150 „Getting ready for dataless files": <https://developer.apple.com/documentation/technotes/tn3150-getting-ready-for-data-less-files>
|
||||
- Apple iCloud Design Guide (File Packages statt Sidecars): <https://developer.apple.com/library/archive/documentation/General/Conceptual/iCloudDesignGuide/Chapters/DesigningForDocumentsIniCloud.html>
|
||||
- Dropbox-Limits (300.000 Dateien, 1 Mio. Writes/24 h): <https://help.dropbox.com/plans/large-deployments>
|
||||
- OneDrive-Limits (300.000 Items): <https://support.microsoft.com/en-us/office/restrictions-and-limitations-in-onedrive-and-sharepoint-64883a5d-228e-48f5-b3d2-eb39e07630fa>
|
||||
|
||||
**Syncthing**
|
||||
- File Versioning: <https://docs.syncthing.net/users/versioning.html>
|
||||
- Syncing / Conflict Handling: <https://docs.syncthing.net/users/syncing.html>
|
||||
- Ignoring Files (`.stignore`): <https://docs.syncthing.net/users/ignoring.html>
|
||||
|
||||
**Git und Rust-Anbindung**
|
||||
- Git Internals — Git Objects: <https://git-scm.com/book/en/v2/Git-Internals-Git-Objects>
|
||||
- `git-config` (`gc.auto`, `gc.autoPackLimit`, `gc.pruneExpire`): <https://git-scm.com/docs/git-config>
|
||||
- `git-gc`: <https://git-scm.com/docs/git-gc>
|
||||
- `git2`: <https://crates.io/crates/git2> · <https://github.com/rust-lang/git2-rs>
|
||||
- `gix` / gitoxide: <https://crates.io/crates/gix> · <https://github.com/GitoxideLabs/gitoxide> · <https://docs.rs/gix/>
|
||||
|
||||
**Windows-Schreibverfahren** (für [#19](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/19), hier nur als Randbefund)
|
||||
- `std::fs::rename`: <https://doc.rust-lang.org/std/fs/fn.rename.html>
|
||||
- `MoveFileExW`: <https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw>
|
||||
- `ReplaceFileW` (Liste der erhaltenen Attribute): <https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew>
|
||||
- Deprecation of TxF (Microsofts Empfehlung für „document-like data"): <https://learn.microsoft.com/en-us/windows/win32/fileio/deprecation-of-txf>
|
||||
- `CreateFileW` (`ERROR_SHARING_VIOLATION`, `FILE_SHARE_DELETE`): <https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew>
|
||||
- `tempfile::NamedTempFile::persist`: <https://docs.rs/tempfile/latest/tempfile/struct.NamedTempFile.html#method.persist>
|
||||
- Dropbox „Conflicted copy" (Ursache: „applications with an auto-save feature"): <https://help.dropbox.com/organize/conflicted-copy>
|
||||
575
docs/research/papierkorb.md
Normal file
575
docs/research/papierkorb.md
Normal file
@@ -0,0 +1,575 @@
|
||||
# Papierkorb unter Rust: Crate, Netzlaufwerke, Kosten
|
||||
|
||||
**Gitea-Issue:** #21 · **Stand der Recherche:** 2026-08-28
|
||||
**Umfeld:** DioNotes, Rust (edition 2021), Dioxus 0.7.1, Default-Feature `desktop`.
|
||||
Entwicklung und Messung unter Windows 11 Enterprise 10.0.26200, cargo/rustc 1.96.0.
|
||||
|
||||
Alle Aussagen sind als **[belegt]** (Primärquelle mit URL, Quellcode, oder eigene reproduzierbare
|
||||
Messung) oder **[Vermutung]** (Schlussfolgerung ohne deckende Quelle) markiert.
|
||||
|
||||
> **Besonderheit dieser Recherche:** Die Kernfrage — was auf Netzlaufwerken passiert — wurde
|
||||
> **nicht erschlossen, sondern gemessen**. Auf dem Entwicklungsrechner sind SMB-Netzlaufwerke
|
||||
> gemountet (`Z:` → `\\la-nas-01.ad.datawin.de\Scan`). Es wurden drei kleine Wegwerf-Programme
|
||||
> außerhalb des Repos gebaut und ausgeführt. Die Rohausgaben stehen in Abschnitt 4.
|
||||
> Im Repo wurde **keine Dependency hinzugefügt**; `Cargo.toml` und `Cargo.lock` sind unverändert.
|
||||
|
||||
---
|
||||
|
||||
## 1. Kurzantwort
|
||||
|
||||
**Die Crate ist `trash`, aktuell `5.2.6`** (veröffentlicht 2026-05-03). Sie ist der De-facto-Standard
|
||||
und wird gepflegt.
|
||||
|
||||
**Aufruf:**
|
||||
|
||||
```rust
|
||||
trash::delete(&pfad)?; // eine Datei
|
||||
trash::delete_all(&pfade)?; // mehrere
|
||||
```
|
||||
|
||||
**Kosten:** **+6 Pakete** im Windows-Graphen. Das ist **nicht** über Cargo-Features reduzierbar —
|
||||
das im Ticket vermutete Gegenstück zum `rfd`/Wayland-Fall **gibt es hier nicht**. Fünf der sechs
|
||||
Pakete sind eine **zweite, ältere Kopie der `windows`-Crate-Familie** (0.56 neben dem bereits
|
||||
vorhandenen 0.61.3), weil `trash 5.2.6` noch auf `windows 0.56.0` festgenagelt ist.
|
||||
|
||||
**Netzlaufwerke — und hier wird es unangenehm, es sind zwei getrennte Probleme:**
|
||||
|
||||
1. **`trash 5.2.6` funktioniert auf Netzlaufwerken schlicht gar nicht.** Der Aufruf schlägt mit
|
||||
einer irreführenden Meldung fehl (`0x80070002`, „Das System kann die angegebene Datei nicht
|
||||
finden"), **die Datei bleibt unangetastet liegen**. Ursache ist ein Pfad-Bug in der Crate, kein
|
||||
Papierkorb-Thema. Immerhin: **nichts geht verloren.**
|
||||
|
||||
2. **Wäre der Bug behoben, wäre das Verhalten gefährlich.** Ruft man dieselbe Windows-API mit
|
||||
denselben Flags, aber unverbogenem Pfad auf, dann wird die Datei auf dem Netzlaufwerk
|
||||
**endgültig und stillschweigend gelöscht** — `PerformOperations()` liefert `Ok(())`,
|
||||
`GetAnyOperationsAborted()` liefert `false`. Am Rückgabewert ist der Fall **nicht** vom
|
||||
erfolgreichen Papierkorb-Verschieben zu unterscheiden.
|
||||
|
||||
**Die Antwort auf die eigentliche Ticketfrage lautet also: Ja, die App braucht eine Vorab-Prüfung
|
||||
bzw. Rückfrage.** Sich auf `Ok(())` zu verlassen, reicht nicht.
|
||||
|
||||
**Erkennen lässt sich der Fall zuverlässig** — aber nur über `IFileOperationProgressSink`, den
|
||||
`trash` nicht durchreicht (die Crate übergibt hart `None`). Details in Abschnitt 5.
|
||||
|
||||
**Blockierend.** Die Dauer ist unabhängig von der Dateigröße (Verschieben innerhalb des Volumes);
|
||||
gemessen: ~740 ms beim allerersten Aufruf (COM-Initialisierung), danach ~27 ms. Abschnitt 6.
|
||||
|
||||
---
|
||||
|
||||
## 2. Welche Crate, wird sie gepflegt?
|
||||
|
||||
### [belegt]
|
||||
|
||||
- **`trash`, aktuelle Version `5.2.6`**, veröffentlicht **2026-05-03**, nicht zurückgezogen.
|
||||
2.683.466 Downloads gesamt, 724.229 „recent". Beschreibung: „A library for moving files and
|
||||
folders to the Recycle Bin".
|
||||
→ <https://crates.io/api/v1/crates/trash>
|
||||
- Letzte Releases: 5.2.6 (2026-05-03), 5.2.5 (2025-10-25), 5.2.4 (2025-10-24), 5.2.3 (2025-08-15),
|
||||
5.2.2 (2025-02-22). Also **regelmäßige Releases, zuletzt vor knapp vier Monaten**.
|
||||
→ <https://crates.io/api/v1/crates/trash>
|
||||
- Das in `Cargo.toml` eingetragene Repository ist `https://github.com/ArturKovacs/trash`; gepflegt
|
||||
und released wird faktisch aus dem Fork **<https://github.com/Byron/trash-rs>** (251 Sterne), auf
|
||||
den auch die CI-Badges und alle Changelog-Commit-Links zeigen.
|
||||
→ `trash-5.2.6/README.md`, `trash-5.2.6/CHANGELOG.md`
|
||||
- Unterstützte Plattformen laut README: „Windows, macOS, and all FreeDesktop Trash compliant
|
||||
environments (including GNOME, KDE, XFCE, and more)".
|
||||
→ `trash-5.2.6/README.md`
|
||||
- `rust-version = "1.85.0"`, `edition = "2021"`, Lizenz MIT.
|
||||
→ `trash-5.2.6/Cargo.toml`
|
||||
- Das Changelog von 5.2.6 zeigt echte, inhaltliche Wartung (Panic durch sauberen Fehler ersetzt,
|
||||
weil `cosmic-files` abstürzte) — die Crate hat reale, anspruchsvolle Nutzer.
|
||||
→ `trash-5.2.6/CHANGELOG.md`
|
||||
|
||||
### [Vermutung]
|
||||
|
||||
Es gibt keine ernstzunehmende Alternative. `rrecycle` und `recyclebin` tauchen in der Suche auf,
|
||||
sind aber ungleich kleiner und teils reine CLI-Werkzeuge. Für DioNotes ist `trash` gesetzt.
|
||||
|
||||
---
|
||||
|
||||
## 3. Was kostet das im Abhängigkeitsgraphen?
|
||||
|
||||
Das Ticket fragt gezielt nach einem Gegenstück zum `rfd`-Fall, wo sich neun Wayland-Pakete durch
|
||||
Angleichen der Features an `dioxus-desktop` vollständig vermeiden ließen.
|
||||
|
||||
### [belegt] Antwort: Ein solches Gegenstück gibt es nicht.
|
||||
|
||||
Die Features von `trash 5.2.6` lauten wörtlich:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
coinit_apartmentthreaded = []
|
||||
coinit_disable_ole1dde = []
|
||||
coinit_multithreaded = []
|
||||
coinit_speed_over_memory = []
|
||||
default = ["coinit_apartmentthreaded", "chrono"]
|
||||
```
|
||||
→ `trash-5.2.6/Cargo.toml`
|
||||
|
||||
**Alle vier `coinit_*`-Features haben leere Dependency-Listen** — es sind reine `cfg`-Schalter für
|
||||
den COM-Initialisierungsmodus (`COINIT_APARTMENTTHREADED` vs. `COINIT_MULTITHREADED`), ohne
|
||||
jede Wirkung auf den Graphen.
|
||||
→ `trash-5.2.6/src/windows.rs`, `struct CoInitializer`
|
||||
|
||||
Das einzige dependency-wirksame Feature ist `chrono`, und das ist **ausschließlich unter
|
||||
`cfg(all(unix, not(macos), not(ios), not(android)))`** deklariert. **Auf Windows ist es
|
||||
wirkungslos.**
|
||||
→ `trash-5.2.6/Cargo.toml`, Abschnitt `[target.'cfg(all(unix, …))'.dependencies.chrono]`
|
||||
|
||||
### [belegt] Gemessene Zahlen
|
||||
|
||||
Basis: `Cargo.lock` im Repo mit 745 Paketen; `cargo tree --edges normal` ergibt 419 eindeutige
|
||||
Pakete für `x86_64-pc-windows-msvc` und 470 für `x86_64-unknown-linux-gnu`.
|
||||
|
||||
| Variante | Windows-Target | Linux-Target |
|
||||
|---|---|---|
|
||||
| `cargo add trash` (default) | **+6** (419 → 425) | +4 (470 → 474) |
|
||||
| `cargo add trash --no-default-features` | **+6** (keine Ersparnis) | +2 (470 → 472) |
|
||||
|
||||
Die sechs neuen Pakete auf Windows:
|
||||
|
||||
```
|
||||
trash v5.2.6
|
||||
windows v0.56.0
|
||||
windows-core v0.56.0
|
||||
windows-implement v0.56.0 (proc-macro)
|
||||
windows-interface v0.56.0 (proc-macro)
|
||||
windows-result v0.1.2
|
||||
```
|
||||
|
||||
### [belegt] Warum die `windows`-Pakete nicht „gratis" sind
|
||||
|
||||
Sie stehen zwar bereits namentlich im `Cargo.lock`, aber in **semver-inkompatiblen** Versionen,
|
||||
werden also **nicht** dedupliziert:
|
||||
|
||||
| Crate | schon im Lock (via dioxus/wry) | von `trash` gefordert | dedupliziert? |
|
||||
|---|---|---|---|
|
||||
| `windows` | 0.61.3 | **0.56.0** | nein |
|
||||
| `windows-core` | 0.61.2 | **0.56.0** | nein |
|
||||
| `windows-implement` | 0.60.2 | **0.56.0** | nein |
|
||||
| `windows-interface` | 0.59.3 | **0.56.0** | nein |
|
||||
| `windows-result` | 0.3.4 | **0.1.2** | nein |
|
||||
|
||||
`cargo tree -i windows@0.56.0` bestätigt: `trash` ist der einzige Konsument dieser zweiten Kopie.
|
||||
|
||||
Wirklich gratis sind nur `log` (0.4.33) und `scopeguard` (1.2.0), die bereits passend im Graphen
|
||||
stehen.
|
||||
|
||||
### [belegt] Auf Linux kostet es fast nichts
|
||||
|
||||
Die FreeDesktop-Implementierung ist reines Rust (`libc`, `once_cell`, `scopeguard`, `urlencoding`,
|
||||
optional `chrono`). **Kein GTK, kein dbus, kein zbus** — der `rfd`-artige Fallstrick existiert hier
|
||||
nicht.
|
||||
→ `trash-5.2.6/Cargo.toml`
|
||||
|
||||
Mit `--no-default-features` fielen auf Linux `chrono` + `iana-time-zone` weg (+2 statt +4). Preis:
|
||||
`trash` kann dann keinen `DeletionDate`-Zeitstempel in die `.trashinfo`-Dateien schreiben. Das ist
|
||||
funktional relevant, nicht bloß kosmetisch. **[Vermutung]** Für DioNotes wäre der Verzicht auf zwei
|
||||
Pakete den Verlust der Zeitstempel nicht wert.
|
||||
|
||||
### [Vermutung] Der einzige echte Hebel
|
||||
|
||||
Ein künftiges `trash`-Release, das auf `windows` ≥ 0.61 aktualisiert, würde die gesamte
|
||||
`windows`-Familie mit dem bestehenden dioxus/wry-Graphen deduplizieren. Der Windows-Delta fiele
|
||||
dann von 6 auf **1** (nur `trash` selbst). Das ist nichts, worauf man warten sollte, aber es ist der
|
||||
Grund, die Version im Blick zu behalten.
|
||||
|
||||
---
|
||||
|
||||
## 4. Netzlaufwerke und USB-Sticks — die Kernfrage
|
||||
|
||||
Das Ticket fragt: Fällt der Aufruf still auf endgültiges Löschen zurück, oder schlägt er fehl?
|
||||
|
||||
**Die Antwort hat zwei Schichten, und sie sind unabhängig voneinander.**
|
||||
|
||||
### 4.1 [belegt] Schicht 1: `trash 5.2.6` schlägt auf Netzlaufwerken fehl — wegen eines Pfad-Bugs
|
||||
|
||||
Gemessen, Wegwerf-Programm gegen `trash 5.2.6`, Datei auf `Z:\` (SMB-Share):
|
||||
|
||||
```
|
||||
=== C: Netzlaufwerk SMB, klein | dir=Z:\ | 1024 bytes ===
|
||||
angelegt: Z:\dionotes-trashprobe-net-small.md
|
||||
trash::delete -> Err(Os { code: -2147024894,
|
||||
description: "windows error: Das System kann die angegebene Datei nicht finden. (0x80070002)" })
|
||||
Dauer: 31.0407ms
|
||||
Datei existiert noch? true
|
||||
Papierkorb-Treffer: vorher=0 nachher=0 => IM PAPIERKORB: false
|
||||
```
|
||||
|
||||
Zum Vergleich, dieselbe Operation lokal auf NTFS:
|
||||
|
||||
```
|
||||
=== A: lokal NTFS, klein | dir=C:\…\Temp | 1024 bytes ===
|
||||
trash::delete -> Ok(())
|
||||
Dauer: 740.7595ms
|
||||
Datei existiert noch? false
|
||||
Papierkorb-Treffer: vorher=0 nachher=1 => IM PAPIERKORB: true
|
||||
```
|
||||
|
||||
**Die Datei auf dem Netzlaufwerk bleibt liegen. Es geht nichts verloren, aber es passiert auch
|
||||
nichts.** Das reproduziert exakt den seit 2022-08-07 offenen Bug
|
||||
[trash-rs#55 „Unable to delete file on Windows shared folder"](https://github.com/Byron/trash-rs/issues/55)
|
||||
(Label `help wanted`, bis heute ungelöst).
|
||||
|
||||
**Die Ursache, aus dem Quellcode und nachgemessen:**
|
||||
|
||||
`trash` kanonisiert jeden Pfad vor dem Löschen mit `std::fs::canonicalize`:
|
||||
|
||||
```rust
|
||||
let canonical_parent = parent.canonicalize()
|
||||
.map_err(|_| Error::CanonicalizePath { original: parent.to_owned() })?;
|
||||
```
|
||||
→ `trash-5.2.6/src/lib.rs`, `fn canonicalize_paths`
|
||||
|
||||
`windows.rs` schneidet danach stumpf die ersten vier UTF-16-Zeichen `\\?\` ab:
|
||||
|
||||
```rust
|
||||
let path_prefix = ['\\' as u16, '\\' as u16, '?' as u16, '\\' as u16];
|
||||
let wide_path_container = to_wide_path(full_path);
|
||||
let wide_path_slice = if wide_path_container.starts_with(&path_prefix) {
|
||||
&wide_path_container[path_prefix.len()..]
|
||||
} else {
|
||||
&wide_path_container[0..]
|
||||
};
|
||||
```
|
||||
→ `trash-5.2.6/src/windows.rs`
|
||||
|
||||
Das geht für lokale Pfade gut und für UNC-Pfade schief. Gemessen:
|
||||
|
||||
```
|
||||
### canonicalize-Verhalten fuer lokal C:
|
||||
canonicalize() = "\\?\C:\Users\marvin.dietermann\AppData\Local\Temp"
|
||||
nach dem Prefix-Stripping = "C:\Users\marvin.dietermann\AppData\Local\Temp" <- gültig
|
||||
|
||||
### canonicalize-Verhalten fuer Netzlaufwerk Z:
|
||||
canonicalize() = "\\?\UNC\la-nas-01.ad.datawin.de\Scan"
|
||||
nach dem Prefix-Stripping = "UNC\la-nas-01.ad.datawin.de\Scan" <- Unsinn
|
||||
|
||||
### canonicalize-Verhalten fuer UNC-Pfad \\la-nas-01.ad.datawin.de\Scan
|
||||
canonicalize() = "\\?\UNC\la-nas-01.ad.datawin.de\Scan"
|
||||
nach dem Prefix-Stripping = "UNC\la-nas-01.ad.datawin.de\Scan" <- Unsinn
|
||||
```
|
||||
|
||||
Aus `\\?\UNC\server\share` wird `UNC\server\share` — ein Pfad, den es nicht gibt.
|
||||
`SHCreateItemFromParsingName` scheitert daran mit `0x80070002`. Korrekt wäre die Rückübersetzung
|
||||
nach `\\server\share`.
|
||||
|
||||
**Betroffen sind beide Schreibweisen** — der gemappte Laufwerksbuchstabe `Z:\` genauso wie der
|
||||
direkte UNC-Pfad, weil `canonicalize` beide auf dieselbe `\\?\UNC\…`-Form abbildet.
|
||||
|
||||
### 4.2 [belegt] Schicht 2: Windows selbst löscht auf Netzlaufwerken still und endgültig
|
||||
|
||||
Um Crate-Bug von Betriebssystemverhalten zu trennen, wurde `IFileOperation` **direkt** aufgerufen —
|
||||
mit exakt denselben Flags wie `trash` (`FOF_NO_UI | FOF_ALLOWUNDO | FOF_WANTNUKEWARNING`,
|
||||
zusammen `0x00004654`), aber mit unverbogenem Pfad:
|
||||
|
||||
```
|
||||
### IFileOperation direkt auf lokal C:
|
||||
PerformOperations -> Ok(()) (Dauer 102.5266ms)
|
||||
GetAnyOperationsAborted -> false
|
||||
Datei existiert noch? false
|
||||
Papierkorb-Treffer: 1 <- im Papierkorb
|
||||
|
||||
### IFileOperation direkt auf Netzlaufwerk Z:
|
||||
PerformOperations -> Ok(()) (Dauer 405.4515ms)
|
||||
GetAnyOperationsAborted -> false
|
||||
Datei existiert noch? false
|
||||
Papierkorb-Treffer: 0 <- WEG. endgueltig.
|
||||
|
||||
### IFileOperation direkt auf UNC-Pfad \\la-nas-01.ad.datawin.de\Scan
|
||||
PerformOperations -> Ok(()) (Dauer 299.5401ms)
|
||||
GetAnyOperationsAborted -> false
|
||||
Datei existiert noch? false
|
||||
Papierkorb-Treffer: 0 <- WEG. endgueltig.
|
||||
```
|
||||
|
||||
**Das ist die eigentliche Warnung des Tickets.** Auf dem Netzlaufwerk wird die Datei endgültig
|
||||
gelöscht, und die API meldet **denselben Erfolg** wie beim Verschieben in den Papierkorb:
|
||||
`Ok(())` und `GetAnyOperationsAborted() == false`. **Am Rückgabewert ist der Unterschied nicht
|
||||
erkennbar.**
|
||||
|
||||
Bemerkenswert: **`FOF_WANTNUKEWARNING` hat keinen Dialog ausgelöst.** Microsoft dokumentiert das
|
||||
Flag als „Send a warning if a file or folder is being destroyed during a delete operation rather
|
||||
than recycled. This flag partially overrides **FOF_NOCONFIRMATION**."
|
||||
→ <https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifileoperation-setoperationflags>
|
||||
|
||||
**[Vermutung]** Die Warnung unterbleibt, weil `trash` zusätzlich `FOF_SILENT` und `FOF_NOERRORUI`
|
||||
setzt: `FOF_NO_UI` ist in der `windows`-Crate als `1556u32` = `0x614` definiert, also
|
||||
`FOF_SILENT (0x4) | FOF_NOCONFIRMATION (0x10) | FOF_NOCONFIRMMKDIR (0x200) | FOF_NOERRORUI (0x400)`.
|
||||
→ `windows-0.56.0/src/Windows/Win32/UI/Shell/mod.rs`, `pub const FOF_NO_UI`
|
||||
Das „partially overrides" bezieht sich laut Doku nur auf `FOF_NOCONFIRMATION`, nicht auf
|
||||
`FOF_SILENT`/`FOF_NOERRORUI`. Belegt ist jedenfalls das Ergebnis: **kein Dialog, keine Rückfrage,
|
||||
Datei weg.**
|
||||
|
||||
### 4.3 USB-Sticks und andere Dateisysteme — [nicht belastbar beantwortet]
|
||||
|
||||
**Das konnte nicht gemessen werden.** Auf dem Testrechner sind zwar zwei Wechseldatenträger
|
||||
(`D:`, `E:`) angemeldet, beide sind aber leer (`FileSystemType = Unknown`, `SizeRemaining = 0`) —
|
||||
offensichtlich Kartenleser ohne Medium. Ohne echten FAT32-/exFAT-Stick lässt sich der Fall nicht
|
||||
verifizieren.
|
||||
|
||||
**[Vermutung]**, ausdrücklich als solche gekennzeichnet: Lokale Wechseldatenträger verhalten sich
|
||||
anders als Netzlaufwerke; Windows legt auf ihnen üblicherweise sehr wohl einen `$RECYCLE.BIN`-
|
||||
bzw. `RECYCLER`-Ordner an. Der kritische Fall ist damit **[Vermutung]** primär das Netzlaufwerk.
|
||||
Verlassen sollte man sich darauf nicht — die in Abschnitt 5 beschriebene Erkennung deckt beide
|
||||
Fälle ab, ohne dass man vorher wissen muss, welcher vorliegt. **Vor der Implementierung sollte
|
||||
dieser Fall mit einem echten USB-Stick nachgemessen werden.**
|
||||
|
||||
Ebenfalls **[Vermutung]**: Auch auf lokalem NTFS kann der Papierkorb fehlen — er lässt sich pro
|
||||
Volume in den Papierkorb-Eigenschaften deaktivieren („Dateien sofort löschen"), und Dateien
|
||||
oberhalb der konfigurierten Maximalgröße gehen ebenfalls direkt verloren. Eine Prüfung, die nur
|
||||
auf den Laufwerkstyp schaut, greift deshalb zu kurz.
|
||||
|
||||
---
|
||||
|
||||
## 5. Wie erfährt das Programm, welcher Fall eingetreten ist?
|
||||
|
||||
### [belegt] Über den Rückgabewert von `trash`: gar nicht
|
||||
|
||||
`trash::delete` liefert `Result<(), Error>`. Die `Error`-Varianten sind `Unknown`, `Os`,
|
||||
`FileSystem` (nur FreeDesktop), `TargetedRoot`, `CouldNotAccess`, `CanonicalizePath`,
|
||||
`ConvertOsString`, `RestoreCollision`, `RestoreTwins`.
|
||||
→ <https://docs.rs/trash/5.2.6/src/trash/lib.rs.html>
|
||||
|
||||
**Es gibt keine Variante und kein Erfolgs-Detail, das „endgültig gelöscht statt Papierkorb"
|
||||
ausdrückt.** Wie Abschnitt 4.2 zeigt, kommt in diesem Fall schlicht `Ok(())` zurück.
|
||||
|
||||
Die Crate wertet lediglich `GetAnyOperationsAborted()` aus, und das war im Netzlaufwerk-Fall
|
||||
`false`:
|
||||
|
||||
```rust
|
||||
pfo.PerformOperations()?;
|
||||
if pfo.GetAnyOperationsAborted()?.as_bool() {
|
||||
// TODO: return the reason why the operation was aborted.
|
||||
return Err(Error::Unknown { description: "Some operations were aborted".into() });
|
||||
}
|
||||
```
|
||||
→ `trash-5.2.6/src/windows.rs`
|
||||
|
||||
### [belegt] Über `IFileOperationProgressSink`: zuverlässig — aber `trash` reicht ihn nicht durch
|
||||
|
||||
Microsoft dokumentiert für `IFileOperationProgressSink::PostDeleteItem` den Parameter
|
||||
`psiNewlyCreated` wörtlich so:
|
||||
|
||||
> „A pointer to an **IShellItem** that specifies the deleted item, now in the Recycle Bin.
|
||||
> **If the item was fully deleted, this value is NULL.**"
|
||||
|
||||
→ <https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifileoperationprogresssink-postdeleteitem>
|
||||
|
||||
Genau das wurde nachgemessen — ein eigener Sink, angehängt an `DeleteItem`:
|
||||
|
||||
```
|
||||
### lokal C:
|
||||
[sink] PreDeleteItem dwFlags=0x00000282
|
||||
[sink] PostDeleteItem dwFlags=0x00000282 hrDelete=0x00270008 psiNewlyCreated=Some
|
||||
==> IM PAPIERKORB: true
|
||||
neuerPfad=Some("C:\$Recycle.Bin\S-1-5-21-…-1668\$R1V9IFD.md")
|
||||
|
||||
### Netzlaufwerk Z:
|
||||
[sink] PreDeleteItem dwFlags=0x00000202
|
||||
[sink] PostDeleteItem dwFlags=0x00000202 hrDelete=0x00270008 psiNewlyCreated=NULL
|
||||
==> IM PAPIERKORB: false
|
||||
neuerPfad=None
|
||||
```
|
||||
|
||||
**Zwei verwertbare Signale, beide belegt:**
|
||||
|
||||
1. **`psiNewlyCreated` in `PostDeleteItem`** — `Some` ⇒ im Papierkorb (inklusive konkretem Pfad im
|
||||
`$Recycle.Bin`), `NULL` ⇒ endgültig gelöscht. Das ist die verlässliche Nachher-Auskunft.
|
||||
`hrDelete` taugt **nicht** dafür: es war in beiden Fällen identisch `0x00270008`.
|
||||
|
||||
2. **`dwFlags` schon in `PreDeleteItem`, also *vor* dem Löschen.** Die Differenz zwischen `0x282`
|
||||
und `0x202` ist genau `0x80` — und das ist `TSF_DELETE_RECYCLE_IF_POSSIBLE`:
|
||||
```
|
||||
pub const TSF_DELETE_RECYCLE_IF_POSSIBLE: _TRANSFER_SOURCE_FLAGS = _TRANSFER_SOURCE_FLAGS(128i32);
|
||||
pub const TSF_COPY_LOCALIZED_NAME: _TRANSFER_SOURCE_FLAGS = _TRANSFER_SOURCE_FLAGS(512i32);
|
||||
pub const TSF_OVERWRITE_EXIST: _TRANSFER_SOURCE_FLAGS = _TRANSFER_SOURCE_FLAGS(2i32);
|
||||
```
|
||||
→ `windows-0.56.0/src/Windows/Win32/UI/Shell/mod.rs`
|
||||
|
||||
Auf `C:` ist das Bit gesetzt, auf `Z:` fehlt es. **Windows sagt also im `PreDeleteItem`-Callback
|
||||
von sich aus, ob es recyceln wird** — bevor irgendetwas passiert ist.
|
||||
|
||||
Und man kann an dieser Stelle abbrechen: Microsoft dokumentiert für die Sink-Rückgabewerte
|
||||
„Returns S_OK if successful, or an error value otherwise. In the case of an error value, all
|
||||
subsequent operations pending from the call to IFileOperation are canceled."
|
||||
→ <https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifileoperationprogresssink-postdeleteitem>
|
||||
**[Vermutung]** Damit ließe sich aus `PreDeleteItem` heraus vetoen und stattdessen erst der
|
||||
Nutzer fragen. Der Abbruch aus `PreDeleteItem` wurde **nicht** gemessen.
|
||||
|
||||
**Der Haken:** `trash` übergibt den Sink-Parameter hart als `None`:
|
||||
|
||||
```rust
|
||||
pfo.DeleteItem(&shi, None)?;
|
||||
```
|
||||
→ `trash-5.2.6/src/windows.rs`
|
||||
|
||||
Über die Crate-API ist an diese Information **nicht** heranzukommen.
|
||||
|
||||
### [Vermutung] Konsequenz für DioNotes
|
||||
|
||||
Drei gangbare Wege, in aufsteigender Sauberkeit:
|
||||
|
||||
1. **Billig und grob:** vor dem Löschen prüfen, ob der Workspace-Pfad auf einem Netzlaufwerk liegt
|
||||
(Laufwerkstyp `DRIVE_REMOTE` bzw. UNC-Pfad), und dann eine Rückfrage stellen. Erkennt den
|
||||
Hauptfall, aber nicht den deaktivierten Papierkorb auf lokalem NTFS.
|
||||
2. **Nachher-Prüfung:** nach `trash::delete` per `trash::os_limited::list()` nachsehen, ob der
|
||||
Eintrag im Papierkorb aufgetaucht ist. Funktioniert (so wurden die Messungen oben verifiziert),
|
||||
ist aber langsam und rennt gegen Race Conditions.
|
||||
3. **Sauber:** `IFileOperation` mit eigenem Sink selbst aufrufen, statt `trash` zu benutzen — dann
|
||||
bekommt man `TSF_DELETE_RECYCLE_IF_POSSIBLE` vorab und `psiNewlyCreated` hinterher, und der
|
||||
UNC-Bug aus 4.1 entfällt ebenfalls, weil man `canonicalize` nicht braucht. Kostet dafür eine
|
||||
eigene Windows-Implementierung und hilft auf Linux/macOS nicht.
|
||||
|
||||
Da `trash 5.2.6` auf Netzlaufwerken ohnehin nicht funktioniert, ist die Entscheidung ohne eine
|
||||
bewusste Behandlung dieses Falls **nicht** vollständig — deshalb ist dieses Ticket die
|
||||
Voraussetzung für die Lösch-Implementierung, nicht bloß eine Fußnote.
|
||||
|
||||
---
|
||||
|
||||
## 6. Blockierend oder asynchron? Wie lange dauert es?
|
||||
|
||||
### [belegt] Blockierend
|
||||
|
||||
`trash::delete` ist eine gewöhnliche synchrone Funktion; es gibt keine `async`-Variante in der
|
||||
öffentlichen API. Der Windows-Pfad ruft `pfo.PerformOperations()` direkt im aufrufenden Thread auf,
|
||||
ohne Thread zu spawnen.
|
||||
→ `trash-5.2.6/src/windows.rs`, <https://docs.rs/trash/5.2.6/src/trash/lib.rs.html>
|
||||
|
||||
### [belegt] Gemessene Dauer
|
||||
|
||||
| Fall | Größe | Dauer |
|
||||
|---|---|---|
|
||||
| lokal NTFS, erster Aufruf im Prozess | 1 KiB | **740,8 ms** |
|
||||
| lokal NTFS, zweiter Aufruf | **512 MiB** | **26,7 ms** |
|
||||
| `IFileOperation` direkt, lokal | 5 B | 102,5 ms |
|
||||
| `IFileOperation` direkt, Netzlaufwerk `Z:` | 5 B | 405,5 ms |
|
||||
| `IFileOperation` direkt, UNC-Pfad | 5 B | 299,5 ms |
|
||||
|
||||
**Die Dateigröße spielt praktisch keine Rolle:** 512 MiB wurden in 26,7 ms erledigt — schneller
|
||||
als die 1-KiB-Datei davor. Das Verschieben in den Papierkorb ist ein Rename **innerhalb desselben
|
||||
Volumes**, es werden keine Daten kopiert.
|
||||
|
||||
Was kostet, ist der **erste** Aufruf im Prozess: die COM-Initialisierung
|
||||
(`CoInitializeEx` + `CoCreateInstance` der `FileOperation`-Klasse) schlägt mit gut 0,7 s zu Buche.
|
||||
`trash` initialisiert COM `thread_local` und einmalig.
|
||||
→ `trash-5.2.6/src/windows.rs`, `thread_local! { static CO_INITIALIZER … }`
|
||||
|
||||
**[Vermutung]** Für DioNotes heißt das: der Aufruf gehört nicht in den UI-Thread. Ein
|
||||
`spawn_blocking` bzw. der `tokio`-Threadpool genügt völlig; eine Fortschrittsanzeige braucht es
|
||||
für einzelne `.md`-Dateien nicht. Netzlaufwerke sind mit ~0,3–0,4 s deutlich träger als lokale
|
||||
Platten, aber immer noch weit unter jeder Schmerzgrenze.
|
||||
|
||||
**Achtung [belegt]:** `trash` initialisiert COM standardmäßig mit `COINIT_APARTMENTTHREADED`
|
||||
(Feature `coinit_apartmentthreaded`, Teil der Defaults) und **panickt**, wenn `CoInitializeEx`
|
||||
fehlschlägt:
|
||||
|
||||
```rust
|
||||
panic!("Call to CoInitializeEx failed. HRESULT: {:?}. Consider using `trash` with the feature `coinit_multithreaded`", hr);
|
||||
```
|
||||
→ `trash-5.2.6/src/windows.rs`
|
||||
|
||||
**[Vermutung]** Ruft man `trash` aus einem Tokio-Worker-Thread auf, der bereits als MTA
|
||||
initialisiert wurde, könnte das zuschlagen; dann ist `default-features = false` plus
|
||||
`features = ["coinit_multithreaded"]` der Ausweg. Das wurde **nicht** gemessen.
|
||||
|
||||
---
|
||||
|
||||
## 7. macOS und Linux
|
||||
|
||||
### [belegt] macOS
|
||||
|
||||
`trash` bietet zwei Verfahren, wählbar über `TrashContext`:
|
||||
|
||||
- **`DeleteMethod::Finder` (Default)** — steuert den Finder per AppleScript.
|
||||
- **`DeleteMethod::NsFileManager`** — ruft `trashItemAtURL` auf `NSFileManager` auf. Laut den
|
||||
Doc-Kommentaren: „Somewhat faster than the `Finder` method", „Does *not* require additional
|
||||
permissions", „Does *not* produce the sound that Finder usually makes", aber „Does *not* show the
|
||||
'Put Back' option on some systems … This is a macOS bug."
|
||||
|
||||
→ `trash-5.2.6/src/macos/mod.rs`
|
||||
|
||||
Fehler von `trashItemAtURL` werden als `Error::Unknown` weitergereicht, nicht verschluckt.
|
||||
Abhängigkeiten: `objc2`, `objc2-foundation`, `percent-encoding` — alle drei stehen **bereits im
|
||||
`Cargo.lock`** (0.6.4 / 0.3.2 / 2.3.2), auf macOS wäre `trash` also nahezu gratis.
|
||||
|
||||
### [belegt] Linux / FreeDesktop
|
||||
|
||||
Implementiert die FreeDesktop-Trash-Spezifikation v1.0. Die Implementierung ist reines Rust, ohne
|
||||
GTK/dbus. Wichtig für die Netzlaufwerk-Frage: Der Code **legt die Trash-Ordner bei Bedarf an** und
|
||||
fällt bei einem geräteübergreifenden Verschieben explizit auf Kopieren-und-Löschen zurück:
|
||||
|
||||
```rust
|
||||
let Err(e) = std::fs::rename(src, dst) else { return Ok(()) };
|
||||
let needs_cross_device_copy = e.kind() == ErrorKind::CrossesDevices;
|
||||
if !needs_cross_device_copy { return Err((src.to_owned(), e)); }
|
||||
debug!("Cross-device move detected, falling back to copy+delete for {:?}", src);
|
||||
```
|
||||
→ `trash-5.2.6/src/freedesktop.rs`
|
||||
|
||||
**[Vermutung]** Damit ist Linux der unproblematischere Fall: Entweder die Datei landet in einem
|
||||
Trash-Verzeichnis, oder es kommt ein `Error::FileSystem` zurück. Ein stilles endgültiges Löschen
|
||||
wie unter Windows ist im Code nicht angelegt. Nicht gemessen — es stand keine Linux-Maschine zur
|
||||
Verfügung.
|
||||
|
||||
---
|
||||
|
||||
## 8. Empfehlung
|
||||
|
||||
**[Vermutung]** — das ist eine Einschätzung, keine belegte Aussage:
|
||||
|
||||
1. **`trash 5.2.6` nehmen**, mit Default-Features. Die +6 Pakete sind der Preis; verhandeln lässt
|
||||
sich daran nichts.
|
||||
2. **Den Netzlaufwerk-Fall vor der ersten Löschfunktion klären, nicht danach.** `trash` schlägt
|
||||
dort heute fehl (4.1) — die App muss diesen Fehler ohnehin abfangen und dem Nutzer erklären,
|
||||
sonst wirkt das Löschen auf Netzlaufwerken schlicht kaputt.
|
||||
3. **Eine Rückfrage vor dem Löschen ist nötig**, sobald der Workspace nicht auf einer lokalen
|
||||
Platte liegt. Der Nutzer muss wissen, ob seine Notiz wiederherstellbar ist — und aus dem
|
||||
Rückgabewert von `trash` erfährt er es nicht (5).
|
||||
4. **Nicht im UI-Thread aufrufen** (6).
|
||||
5. **Die `trash`-Version im Blick behalten**: sobald sie auf `windows` ≥ 0.61 geht, halbieren sich
|
||||
die Kosten, und `trash-rs#55` könnte bei der Gelegenheit mit erledigt werden.
|
||||
|
||||
---
|
||||
|
||||
## 9. Reproduktion
|
||||
|
||||
Die Messungen stammen aus drei Wegwerf-Programmen, die **außerhalb des Repos** gebaut wurden
|
||||
(Scratchpad, nicht eingecheckt):
|
||||
|
||||
- `main.rs` — `trash::delete` gegen lokal / Netzlaufwerk, mit Papierkorb-Gegenprobe über
|
||||
`trash::os_limited::list()`
|
||||
- `raw.rs` — `IFileOperation` direkt, mit denselben Flags, ohne `canonicalize`; zeigt zusätzlich
|
||||
das Prefix-Stripping-Verhalten
|
||||
- `sink.rs` — eigener `IFileOperationProgressSink` (`#[implement]` aus `windows 0.56`), protokolliert
|
||||
`PreDeleteItem`/`PostDeleteItem`
|
||||
|
||||
Alle angelegten Testdateien wurden wieder entfernt, die Papierkorb-Einträge mit
|
||||
`trash::os_limited::purge_all` endgültig gelöscht. Auf `Z:` sind keine Rückstände verblieben.
|
||||
|
||||
**Im Repo wurde nichts an `Cargo.toml` oder `Cargo.lock` geändert** — die Dependency-Messung lief
|
||||
über `cargo add` mit anschließendem `git checkout --`, verifiziert mit leerem
|
||||
`git status --porcelain`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Quellen
|
||||
|
||||
- crates.io-API zu `trash`: <https://crates.io/api/v1/crates/trash>
|
||||
- API-Doku: <https://docs.rs/trash/latest/trash/>
|
||||
- Quelltext `lib.rs` (Error-Enum, `canonicalize_paths`):
|
||||
<https://docs.rs/trash/5.2.6/src/trash/lib.rs.html>
|
||||
- Quelltext `windows.rs`, `freedesktop.rs`, `macos/mod.rs`, `Cargo.toml`, `README.md`,
|
||||
`CHANGELOG.md`: aus dem Crate-Tarball
|
||||
<https://static.crates.io/crates/trash/5.2.6/download>
|
||||
(auf docs.rs **nicht** einsehbar — docs.rs baut auf Linux, `windows.rs` ist dort wegcfg-t)
|
||||
- Repository (gepflegter Fork): <https://github.com/Byron/trash-rs>
|
||||
- Offener Bug zum Netzlaufwerk-Fall: <https://github.com/Byron/trash-rs/issues/55>
|
||||
- `IFileOperation::SetOperationFlags` (FOF_-/FOFX_-Flags, `FOF_WANTNUKEWARNING`):
|
||||
<https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifileoperation-setoperationflags>
|
||||
- `IFileOperationProgressSink::PostDeleteItem` (`psiNewlyCreated` ist NULL bei endgültiger Löschung):
|
||||
<https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifileoperationprogresssink-postdeleteitem>
|
||||
- Konstanten `FOF_NO_UI`, `TSF_DELETE_RECYCLE_IF_POSSIBLE` u. a.: generierte Bindings in
|
||||
`windows 0.56.0`, `src/Windows/Win32/UI/Shell/mod.rs`
|
||||
- FreeDesktop Trash Specification v1.0:
|
||||
<https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html>
|
||||
18
package-lock.json
generated
Normal file
18
package-lock.json
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "dionotes-client",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"tailwindcss": "^4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"tailwindcss": "^4.3.3"
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ mod workspace_list;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::route::Route;
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
@@ -15,6 +17,7 @@ const FAVICON: Asset = asset!("/assets/favicon.ico");
|
||||
// Linked individually rather than via @import in a single entry file: asset!()
|
||||
// only emits files it is given by name, so an @import chain would 404 at runtime.
|
||||
// Order matters — tokens, then baseline, then layout, then components.
|
||||
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
|
||||
const TOKENS_CSS: Asset = asset!("/assets/css/tokens.css");
|
||||
const BASE_CSS: Asset = asset!("/assets/css/base.css");
|
||||
const LAYOUT_CSS: Asset = asset!("/assets/css/layout.css");
|
||||
@@ -30,9 +33,15 @@ fn App() -> Element {
|
||||
// gesetzt und in der Datei-Leiste gelesen — beide hängen unter einem
|
||||
// Outlet, durch das sich keine Props durchreichen lassen.
|
||||
use_context_provider(|| Signal::new(None::<Workspace>));
|
||||
// Die im Editor geöffnete Notiz. Getrennt vom Workspace, weil sie sich
|
||||
// ändert, ohne dass der Workspace wechselt.
|
||||
use_context_provider(|| Signal::new(None::<PathBuf>));
|
||||
|
||||
rsx! {
|
||||
document::Link { rel: "icon", href: FAVICON }
|
||||
// Tailwind first: its preflight lives in @layer base, our unlayered
|
||||
// rules win the cascade regardless, but this keeps source order honest.
|
||||
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
|
||||
document::Link { rel: "stylesheet", href: TOKENS_CSS }
|
||||
document::Link { rel: "stylesheet", href: BASE_CSS }
|
||||
document::Link { rel: "stylesheet", href: LAYOUT_CSS }
|
||||
|
||||
@@ -24,14 +24,35 @@ pub fn Home() -> Element {
|
||||
|
||||
#[component]
|
||||
pub fn Editor() -> Element {
|
||||
let note = use_context::<Signal<Option<PathBuf>>>();
|
||||
let mut source = use_signal(String::new);
|
||||
|
||||
// Beim Wechsel der Notiz den Inhalt nachladen — auf einem eigenen Thread,
|
||||
// damit eine große Datei auf langsamem Laufwerk nicht die Oberfläche hält.
|
||||
use_effect(move || {
|
||||
let path = note.read().clone();
|
||||
spawn(async move {
|
||||
let text = match path {
|
||||
Some(path) => tokio::task::spawn_blocking(move || {
|
||||
std::fs::read_to_string(path).unwrap_or_default()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
None => String::new(),
|
||||
};
|
||||
source.set(text);
|
||||
});
|
||||
});
|
||||
|
||||
rsx! {
|
||||
CodeEditor {
|
||||
class: "editor-class",
|
||||
value: source(),
|
||||
language: Language::Markdown,
|
||||
theme: Theme::CATPPUCCIN_MOCHA,
|
||||
// TODO: Änderungen landen bisher nirgends. Speichern, Autosave und
|
||||
// der Umgang mit ungesicherten Änderungen gehören zum
|
||||
// Notiz-Handling und damit in den nächsten Effort.
|
||||
oninput: move |value| source.set(value),
|
||||
}
|
||||
}
|
||||
@@ -119,9 +140,12 @@ fn TreeLevel(nodes: Vec<TreeNode>, expanded: Signal<HashSet<PathBuf>>, depth: us
|
||||
|
||||
#[component]
|
||||
fn TreeEntry(node: TreeNode, mut expanded: Signal<HashSet<PathBuf>>, depth: usize) -> Element {
|
||||
let mut note = use_context::<Signal<Option<PathBuf>>>();
|
||||
let navigator = use_navigator();
|
||||
let path = node.path().to_path_buf();
|
||||
let name = node.name().to_string();
|
||||
let is_open = expanded.read().contains(&path);
|
||||
let is_current = note.read().as_deref() == Some(path.as_path());
|
||||
|
||||
match node {
|
||||
TreeNode::Folder { children, .. } => {
|
||||
@@ -144,15 +168,20 @@ fn TreeEntry(node: TreeNode, mut expanded: Signal<HashSet<PathBuf>>, depth: usiz
|
||||
}
|
||||
}
|
||||
}
|
||||
TreeNode::File { .. } => rsx! {
|
||||
// TODO: Klick öffnet die Notiz im Editor. Gehört zum Notiz-Handling
|
||||
// und damit in den nächsten Effort, nicht in diese Map.
|
||||
button {
|
||||
class: "file-selector",
|
||||
style: "--depth: {depth}",
|
||||
span { class: "tree-caret" }
|
||||
"{name}"
|
||||
TreeNode::File { .. } => {
|
||||
let opened = path.clone();
|
||||
rsx! {
|
||||
button {
|
||||
class: if is_current { "file-selector active" } else { "file-selector" },
|
||||
style: "--depth: {depth}",
|
||||
onclick: move |_| {
|
||||
note.set(Some(opened.clone()));
|
||||
navigator.push(Route::Editor {});
|
||||
},
|
||||
span { class: "tree-caret" }
|
||||
"{name}"
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::workspace_list::WorkspaceList;
|
||||
#[component]
|
||||
pub fn Selector() -> Element {
|
||||
let mut workspace = use_context::<Signal<Option<Workspace>>>();
|
||||
let mut note = use_context::<Signal<Option<PathBuf>>>();
|
||||
let mut list = use_signal(WorkspaceList::load);
|
||||
let navigator = use_navigator();
|
||||
|
||||
@@ -50,18 +51,32 @@ pub fn Selector() -> Element {
|
||||
)));
|
||||
}
|
||||
workspace.set(Some(opened));
|
||||
navigator.push(Route::Home {});
|
||||
// Mit Notiz direkt in den Editor, sonst auf den leeren Stand.
|
||||
if note.read().is_some() {
|
||||
navigator.push(Route::Editor {});
|
||||
} else {
|
||||
navigator.push(Route::Home {});
|
||||
}
|
||||
};
|
||||
|
||||
let mut create_at = move |root: PathBuf| match Workspace::create(root) {
|
||||
Ok(opened) => accept(opened),
|
||||
Ok(opened) => {
|
||||
// Ein frischer Workspace bringt eine Kurzanleitung mit; die wird
|
||||
// gleich angezeigt, statt den Nutzer vor eine leere Fläche zu setzen.
|
||||
note.set(opened.welcome_note());
|
||||
accept(opened);
|
||||
}
|
||||
Err(err) => error.set(Some(format!("Anlegen fehlgeschlagen: {err}"))),
|
||||
};
|
||||
|
||||
// Ein Klick versucht es erneut — damit ist „Stick wieder eingesteckt"
|
||||
// ohne eigenen Knopf abgedeckt.
|
||||
let mut open_entry = move |root: PathBuf| match Workspace::open(root) {
|
||||
Ok(opened) => accept(opened),
|
||||
Ok(opened) => {
|
||||
// Ein bestehender Workspace öffnet ohne vorgewählte Notiz.
|
||||
note.set(None);
|
||||
accept(opened);
|
||||
}
|
||||
Err(err) => error.set(Some(format!("Öffnen fehlgeschlagen: {err}"))),
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,35 @@ use std::path::{Path, PathBuf};
|
||||
const MARKER_DIR: &str = ".dionotes";
|
||||
const MARKER_FILE: &str = "workspace.ron";
|
||||
|
||||
/// Kurzanleitung, die ein frisch angelegter Workspace mitbekommt.
|
||||
pub const WELCOME_FILE: &str = "Willkommen.md";
|
||||
|
||||
const WELCOME_TEXT: &str = "# Willkommen bei DioNotes
|
||||
|
||||
Dieser Ordner ist jetzt ein **Workspace** — eine Sammlung von Notizen, die als
|
||||
gewöhnliche Markdown-Dateien nebeneinander liegen. Du kannst sie jederzeit mit
|
||||
jedem anderen Programm öffnen, sichern oder synchronisieren.
|
||||
|
||||
## Wie es aufgebaut ist
|
||||
|
||||
- Jede Notiz ist eine `.md`-Datei in diesem Ordner.
|
||||
- **Unterordner funktionieren.** Sie erscheinen links als aufklappbarer Baum.
|
||||
- Der versteckte Ordner `.dionotes` macht diesen Ordner zum Workspace. Dort
|
||||
landen später auch die Einstellungen. Lösche ihn nicht — ohne ihn erkennt
|
||||
DioNotes den Ordner nicht wieder.
|
||||
- Alles, was mit einem Punkt beginnt, bleibt in der Liste unsichtbar. Ein
|
||||
`.git`-Ordner stört also nicht.
|
||||
|
||||
## Gut zu wissen
|
||||
|
||||
DioNotes liest den Ordner beim Öffnen einmal ein. Änderst du etwas von außen —
|
||||
etwa über einen Sync-Dienst —, hol dir den neuen Stand mit dem Knopf **⟳** über
|
||||
der Notizliste.
|
||||
|
||||
Diese Datei ist eine ganz normale Notiz. Du kannst sie überschreiben oder
|
||||
löschen, sobald du sie nicht mehr brauchst.
|
||||
";
|
||||
|
||||
/// Aktuelle Fassung des Marker-Schemas. Erhöhen, sobald Felder dazukommen,
|
||||
/// die eine ältere App nicht bloß ignorieren kann.
|
||||
const SCHEMA_VERSION: u32 = 1;
|
||||
@@ -101,6 +130,12 @@ impl Workspace {
|
||||
root.join(MARKER_DIR).join(MARKER_FILE)
|
||||
}
|
||||
|
||||
/// Die Willkommensnotiz, falls dieser Workspace eine bekommen hat.
|
||||
pub fn welcome_note(&self) -> Option<PathBuf> {
|
||||
let path = self.root.join(WELCOME_FILE);
|
||||
path.is_file().then_some(path)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -133,6 +168,10 @@ impl Workspace {
|
||||
if !root.is_dir() {
|
||||
return Err(OpenError::Unreachable);
|
||||
}
|
||||
// Zuerst prüfen: Sobald `.dionotes` angelegt ist, ist der Ordner
|
||||
// nie mehr leer.
|
||||
let was_empty = dir_is_empty(&root).unwrap_or(false);
|
||||
|
||||
let marker_path = Self::marker_path(&root);
|
||||
if let Some(dir) = marker_path.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
@@ -141,6 +180,14 @@ impl Workspace {
|
||||
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)?;
|
||||
|
||||
// Nur in einen frischen Workspace. Wer einen Ordner voller Notizen
|
||||
// importiert, will keine fremde Datei dazwischen — er kennt das
|
||||
// Programm dann meist schon.
|
||||
if was_empty {
|
||||
let _ = fs::write(root.join(WELCOME_FILE), WELCOME_TEXT);
|
||||
}
|
||||
|
||||
Ok(Self { root })
|
||||
}
|
||||
}
|
||||
@@ -179,6 +226,26 @@ mod tests {
|
||||
assert_eq!(Workspace::open(dir.clone()).expect("oeffenbar"), created);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_workspace_gets_a_welcome_note() {
|
||||
let dir = scratch("welcome-fresh");
|
||||
let ws = Workspace::create(dir.clone()).expect("anlegbar");
|
||||
|
||||
let note = ws.welcome_note().expect("Willkommensnotiz vorhanden");
|
||||
assert!(note.ends_with("Willkommen.md"));
|
||||
assert!(fs::read_to_string(¬e).unwrap().contains("Workspace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_imported_folder_gets_no_welcome_note() {
|
||||
let dir = scratch("welcome-import");
|
||||
fs::write(dir.join("eigene-notiz.md"), "# Meins").unwrap();
|
||||
|
||||
let ws = Workspace::create(dir.clone()).expect("anlegbar");
|
||||
assert!(ws.welcome_note().is_none(), "Import darf keine fremde Datei bekommen");
|
||||
assert!(!dir.join("Willkommen.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_leaves_existing_files_alone() {
|
||||
let dir = scratch("create-keeps-files");
|
||||
|
||||
1
tailwind.css
Normal file
1
tailwind.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
Reference in New Issue
Block a user