feat(media): images and recordings in backups, exports and imports (M6)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s

- Backup ZIP (download and automatic per-user server backups) carries the
  files of the user's own notes as media/<id><ext>; restoring creates
  them with new IDs and rewrites the blocks.
- Markdown archive puts them under attachments/ and embeds them relative
  to each note (![caption](../attachments/…)); re-importing turns them
  back into image/audio blocks.
- Obsidian import takes images and audio a note embeds on its own
  (![[file.png]], ![](path/file.png)); unused or invalid files appear as
  skipped in the preview instead of vanishing.
- Format detection and file paths moved into storage for this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 23:41:26 +01:00
1 parent 3f2bc96732
commit 1f63e08a73
13 files changed
+608 -82

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
- Bilder in Notizen: `/bild` in einem leeren Punkt öffnet direkt im Punkt eine Auswahl – Datei, Kamera (Handy), Hineinziehen, Einfügen mit Strg+V oder Bild-Link (der Server übernimmt eine Kopie). Fotos werden vor dem Hochladen auf höchstens 2048 px verkleinert und als WebP gespeichert, Standortdaten entfallen dabei. Klick vergrößert, darunter eine optionale Beschriftung. Esc oder ✕ macht den Punkt wieder zum leeren Punkt. - Bilder in Notizen: `/bild` in einem leeren Punkt öffnet direkt im Punkt eine Auswahl – Datei, Kamera (Handy), Hineinziehen, Einfügen mit Strg+V oder Bild-Link (der Server übernimmt eine Kopie). Fotos werden vor dem Hochladen auf höchstens 2048 px verkleinert und als WebP gespeichert, Standortdaten entfallen dabei. Klick vergrößert, darunter eine optionale Beschriftung. Esc oder ✕ macht den Punkt wieder zum leeren Punkt.
- Sprachnotizen in Notizen: `/audio` in einem leeren Punkt zeigt eine Aufnahme-Blase – grüner Knopf startet, er wird rot und links laufen Live-Wellen mit der Zeit, ein Tipp speichert die Aufnahme als Audio-Player im Punkt. Alternativ eine Audiodatei hochladen. Mit eigener Spracherkennung macht „Transkribieren“ aus der Aufnahme die (durchsuchbare) Beschriftung. - Sprachnotizen in Notizen: `/audio` in einem leeren Punkt zeigt eine Aufnahme-Blase – grüner Knopf startet, er wird rot und links laufen Live-Wellen mit der Zeit, ein Tipp speichert die Aufnahme als Audio-Player im Punkt. Alternativ eine Audiodatei hochladen. Mit eigener Spracherkennung macht „Transkribieren“ aus der Aufnahme die (durchsuchbare) Beschriftung.
- Diktat in Notizen: `/diktat` in einem leeren Punkt hört sofort zu; der Punkt wird zu einer Fläche mit Live-Wellen und „Stopp“, ein Tipp setzt den erkannten Text in den Punkt. Nutzt die eingestellte Spracherkennung (eigener Server oder Browser) und wird ohne Spracherkennung nicht angeboten. - Diktat in Notizen: `/diktat` in einem leeren Punkt hört sofort zu; der Punkt wird zu einer Fläche mit Live-Wellen und „Stopp“, ein Tipp setzt den erkannten Text in den Punkt. Nutzt die eingestellte Spracherkennung (eigener Server oder Browser) und wird ohne Spracherkennung nicht angeboten.
- Backup und Export mit Medien: Das Backup-ZIP (auch die automatischen Server-Backups pro Konto) enthält die Bilder und Sprachnotizen der eigenen Notizen, das Markdown-Archiv legt sie unter `attachments/` ab und bettet sie Obsidian-kompatibel ein. Beim Zurückspielen kommen sie wieder als Bild- und Audioblöcke. Der Obsidian-Import übernimmt jetzt auch eingebettete Bilder und Audiodateien (`![[bild.png]]`, `![](bild.png)`).
- Medienablage für Notizen (Grundlage für Bilder und Audio): Hochladen, Kopieren externer Bilder, Abruf nur mit Leserecht; Grenzen per `MEDIA_*`; nicht mehr verwendete Dateien werden automatisch entfernt. API: `POST /documents/{id}/media`, `POST /documents/{id}/media/fetch`, `GET /media/{id}`. - Medienablage für Notizen (Grundlage für Bilder und Audio): Hochladen, Kopieren externer Bilder, Abruf nur mit Leserecht; Grenzen per `MEDIA_*`; nicht mehr verwendete Dateien werden automatisch entfernt. API: `POST /documents/{id}/media`, `POST /documents/{id}/media/fetch`, `GET /media/{id}`.
- Code-Blöcke in Notizen: Sprachauswahl (rund 50 Sprachen, bei Bedarf nachgeladen), Farben beim Tippen im Stil von VS Code Dark+/Light+ für alle Themes, „Kopieren“, Tab/Shift+Tab rückt ein und aus. `/code yaml` oder ```` ```yaml ```` legt die Sprache direkt fest. - Code-Blöcke in Notizen: Sprachauswahl (rund 50 Sprachen, bei Bedarf nachgeladen), Farben beim Tippen im Stil von VS Code Dark+/Light+ für alle Themes, „Kopieren“, Tab/Shift+Tab rückt ein und aus. `/code yaml` oder ```` ```yaml ```` legt die Sprache direkt fest.
+3 -2
View File
@@ -92,7 +92,7 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
## Medien in Notizen ## Medien in Notizen
Bilder und Audio liegen in `$DATA_DIR/media/<docId>/<id>.<ext>`, beschrieben in der Tabelle `media` (Migration 5, `internal/storage/media.go`). Ein Block verweist über seinen Inhalt darauf, z. B. Typ `image` mit `![Beschriftung](media:med_…)`; damit laufen Verweise ohne Änderung am Sync-Modell durch Synchronisation, Rückgängig und Konflikte. Hochladen (`POST /documents/{id}/media`) braucht Schreibrecht auf die Notiz, Lesen (`GET /media/{id}`) Leserecht auf die Notiz der Datei oder auf eine Notiz, deren Blöcke sie zeigen (kopierte Blöcke). Das Format wird am Inhalt erkannt (JPEG, PNG, GIF, WebP; WebM, Ogg, MP3, WAV, M4A), SVG und alles andere wird abgelehnt; Antworten tragen eine eigene CSP mit `sandbox`. Externe Bilder kopiert der Server einmal (`POST /documents/{id}/media/fetch`, nur https und öffentliche Adressen wie bei Webhooks, nicht in der Demo). Grenzen: `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB`, `MEDIA_QUOTA_MB`, in der Demo `MEDIA_DEMO_MAX_MB`. Die Verdichtung (alle 6 Stunden) löscht Dateien, die kein Block mehr erwähnt (auch keine gelöschten im Papierkorb oder Protokoll), frühestens einen Tag nach dem Hochladen. Der Demo-Reset leert `media/`. Bilder und Audio liegen in `$DATA_DIR/media/<docId>/<id>.<ext>`, beschrieben in der Tabelle `media` (Migration 5, `internal/storage/media.go`). Ein Block verweist über seinen Inhalt darauf, z. B. Typ `image` mit `![Beschriftung](media:med_…)`; damit laufen Verweise ohne Änderung am Sync-Modell durch Synchronisation, Rückgängig und Konflikte. Hochladen (`POST /documents/{id}/media`) braucht Schreibrecht auf die Notiz, Lesen (`GET /media/{id}`) Leserecht auf die Notiz der Datei oder auf eine Notiz, deren Blöcke sie zeigen (kopierte Blöcke). Das Format wird am Inhalt erkannt (JPEG, PNG, GIF, WebP; WebM, Ogg, MP3, WAV, M4A), SVG und alles andere wird abgelehnt; Antworten tragen eine eigene CSP mit `sandbox`. Externe Bilder kopiert der Server einmal (`POST /documents/{id}/media/fetch`, nur https und öffentliche Adressen wie bei Webhooks, nicht in der Demo). Grenzen: `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB`, `MEDIA_QUOTA_MB`, in der Demo `MEDIA_DEMO_MAX_MB`. Backup-ZIP (`media/<id><ext>`) und Markdown-Archiv (`attachments/`, Einbettung `![Beschriftung](../attachments/…)` relativ zur Notiz) enthalten die Dateien der eigenen Notizen; beim Import bekommen sie neue IDs. Der Obsidian-Import übernimmt Bilder und Audio, die eine Notiz allein in einem Punkt einbettet (`![[datei.png]]` oder `![](pfad/datei.png)`); andere Dateien erscheinen in der Vorschau als übersprungen. Die Verdichtung (alle 6 Stunden) löscht Dateien, die kein Block mehr erwähnt (auch keine gelöschten im Papierkorb oder Protokoll), frühestens einen Tag nach dem Hochladen. Der Demo-Reset leert `media/`.
## Spracherkennung ## Spracherkennung
@@ -128,7 +128,8 @@ Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go te
[backups.go](../internal/api/backups.go) / [backup.go](../internal/storage/backup.go): Ist „Automatische Backups“ in den Admin-Einstellungen an, läuft einmal täglich zur eingestellten Uhrzeit (`backup_time`, Zeitzone `TZ`; nach einem Neustart wird ein verpasster Lauf am selben Tag nachgeholt): [backups.go](../internal/api/backups.go) / [backup.go](../internal/storage/backup.go): Ist „Automatische Backups“ in den Admin-Einstellungen an, läuft einmal täglich zur eingestellten Uhrzeit (`backup_time`, Zeitzone `TZ`; nach einem Neustart wird ein verpasster Lauf am selben Tag nachgeholt):
- `backups/system/mindsift-db-JJJJ-MM-TT-HHMM.db.gz`: Abzug der ganzen Datenbank per `VACUUM INTO` (stimmig, auch während geschrieben wird), gzip. - `backups/system/mindsift-db-JJJJ-MM-TT-HHMM.db.gz`: Abzug der ganzen Datenbank per `VACUUM INTO` (stimmig, auch während geschrieben wird), gzip.
- `backups/users/<id>/mindsift-backup-….zip`: dasselbe ZIP wie „Backup herunterladen“ für jedes aktive Konto. - `backups/users/<id>/mindsift-backup-….zip`: dasselbe ZIP wie „Backup herunterladen“ für jedes aktive Konto, mit den Bildern und Aufnahmen der eigenen Notizen unter `media/`.
- **Achtung:** Der Datenbank-Abzug enthält keine Mediendateien. Wer den Server sichert, sichert den ganzen Ordner `data/` (Datenbank, `media/`, `backups/`).
- Danach werden Dateien älter als `backup_retention_days` (Standard 3) gelöscht. Dateien entstehen über `.part` und Umbenennen, halbe Backups tauchen nie in der Liste auf. Die API liefert nur Dateien mit genau diesem Namensmuster aus. - Danach werden Dateien älter als `backup_retention_days` (Standard 3) gelöscht. Dateien entstehen über `.part` und Umbenennen, halbe Backups tauchen nie in der Liste auf. Die API liefert nur Dateien mit genau diesem Namensmuster aus.
- Im Demo-Modus läuft kein Zeitplan. Zurückspielen eines Datenbank-Abzugs: Container stoppen, `mindsift.db` durch die entpackte Datei ersetzen, starten. Benutzer spielen ihre eigenen Backups in den Kontoeinstellungen ein, auch nur für einzelne Bereiche (Notizen, Aufgaben, Listen, Inbox, Themes). - Im Demo-Modus läuft kein Zeitplan. Zurückspielen eines Datenbank-Abzugs: Container stoppen, `mindsift.db` durch die entpackte Datei ersetzen, starten. Benutzer spielen ihre eigenen Backups in den Kontoeinstellungen ein, auch nur für einzelne Bereiche (Notizen, Aufgaben, Listen, Inbox, Themes).
+1 -1
View File
@@ -210,7 +210,7 @@ Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank,
## 🔒 Deine Daten gehören dir ## 🔒 Deine Daten gehören dir
* Selbst gehostet, keine Telemetrie, keine Fremddienste. Ausgehende Verbindungen gibt es nur zu den Alarm-Zielen, die du selbst einrichtest (SMTP, Discord, Webhook). * Selbst gehostet, keine Telemetrie, keine Fremddienste. Ausgehende Verbindungen gibt es nur zu den Alarm-Zielen, die du selbst einrichtest (SMTP, Discord, Webhook).
* Alle Inhalte liegen in einer einzigen SQLite-Datei; der ganze Workspace lässt sich als Obsidian-kompatibles Markdown-Archiv (ZIP) exportieren, alle Daten eines Benutzers als JSON. * Alle Inhalte liegen in einer einzigen SQLite-Datei; Bilder und Sprachnotizen liegen daneben in `data/media/`. Der ganze Workspace lässt sich als Obsidian-kompatibles Markdown-Archiv (ZIP, mit `attachments/`) exportieren, alle Daten eines Benutzers als Backup-ZIP (JSON plus Mediendateien).
--- ---
@@ -1213,8 +1213,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<div> <div>
<h3 className="font-bold text-main">Vollständiges Backup (.zip)</h3> <h3 className="font-bold text-main">Vollständiges Backup (.zip)</h3>
<p className="text-muted text-[11px]"> <p className="text-muted text-[11px]">
Alle deine Gedanken, Aufgaben, Notizen und Listen als JSON, gepackt in eine ZIP-Datei. Über Alle deine Gedanken, Aufgaben, Notizen und Listen als JSON, mit den Bildern und Sprachnotizen deiner
„MindSift-Daten importieren“ lässt es sich vollständig zurückspielen. Notizen, gepackt in eine ZIP-Datei. Über „MindSift-Daten importieren“ lässt es sich vollständig zurückspielen.
</p> </p>
</div> </div>
<button <button
@@ -1234,8 +1234,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<p className="text-muted text-[11px]"> <p className="text-muted text-[11px]">
Alle Notizen als <code className="font-mono">.md</code>-Dateien in deiner Ordnerstruktur (mit Titel, Tags und Alle Notizen als <code className="font-mono">.md</code>-Dateien in deiner Ordnerstruktur (mit Titel, Tags und
Datum im Kopf), Aufgaben je Liste unter <code className="font-mono">Aufgaben/</code> im Format des Datum im Kopf), Aufgaben je Liste unter <code className="font-mono">Aufgaben/</code> im Format des
Obsidian-Tasks-Plugins, Listen unter <code className="font-mono">Listen/</code>. Mit dir geteilte Notizen sind Obsidian-Tasks-Plugins, Listen unter <code className="font-mono">Listen/</code>, Bilder und Sprachnotizen unter{' '}
dabei. <code className="font-mono">attachments/</code>. Mit dir geteilte Notizen sind dabei.
</p> </p>
{vaultError && <p className="text-[11px] text-status-err mt-1">{vaultError}</p>} {vaultError && <p className="text-[11px] text-status-err mt-1">{vaultError}</p>}
</div> </div>
@@ -1278,7 +1278,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<> <>
Vault als <code className="font-mono">.zip</code>. Alle Notizen landen in einem eigenen Ordner mit dem Namen Vault als <code className="font-mono">.zip</code>. Alle Notizen landen in einem eigenen Ordner mit dem Namen
des Vaults, Unterordner bleiben erhalten. Checkboxen bleiben Checkboxen in der Notiz, Frontmatter-Tags werden zu des Vaults, Unterordner bleiben erhalten. Checkboxen bleiben Checkboxen in der Notiz, Frontmatter-Tags werden zu
#Hashtags. Bilder und andere Anhänge werden übersprungen. #Hashtags. Bilder und Audiodateien, die eine Notiz einbettet (<code className="font-mono">![[bild.png]]</code>{' '}
oder <code className="font-mono">![](bild.png)</code>), werden übernommen; andere Anhänge wie PDFs nicht.
</> </>
} }
/> />
+1 -1
View File
@@ -156,7 +156,7 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("format") == "zip" { if r.URL.Query().Get("format") == "zip" {
w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-backup-%s.zip"`, time.Now().Format("2006-01-02"))) w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-backup-%s.zip"`, time.Now().Format("2006-01-02")))
if err := storage.WriteBackupZip(exportData, w); err != nil { if err := s.store.WriteBackupZip(userID, exportData, w); err != nil {
slog.Error("Backup-Export fehlgeschlagen", "user_id", userID, "error", err) slog.Error("Backup-Export fehlgeschlagen", "user_id", userID, "error", err)
} }
return return
+3 -59
View File
@@ -23,35 +23,9 @@ import (
const mediaGrace = 24 * time.Hour // uploads not yet placed in a block are kept this long const mediaGrace = 24 * time.Hour // uploads not yet placed in a block are kept this long
// Accepted formats (detected from the content, not from the name): file extension and kind func (s *Server) mediaRoot() string { return s.store.MediaRoot() }
var mediaTypes = map[string]struct{ ext, kind string }{
"image/jpeg": {".jpg", "image"},
"image/png": {".png", "image"},
"image/gif": {".gif", "image"},
"image/webp": {".webp", "image"},
"audio/webm": {".webm", "audio"},
"video/webm": {".webm", "audio"}, // Chrome's audio-only recordings are detected as video/webm
"audio/ogg": {".ogg", "audio"},
"application/ogg": {".ogg", "audio"},
"audio/mpeg": {".mp3", "audio"},
"audio/wave": {".wav", "audio"},
"audio/wav": {".wav", "audio"},
"audio/mp4": {".m4a", "audio"},
"video/mp4": {".m4a", "audio"}, // Safari records MP4/AAC
"audio/aac": {".aac", "audio"},
}
func (s *Server) mediaRoot() string { func (s *Server) mediaPath(m *storage.Media) string { return s.store.MediaFilePath(m) }
return filepath.Join(filepath.Dir(s.cfg.DatabasePath), "media")
}
func (s *Server) mediaPath(m *storage.Media) string {
ext := ".bin"
if t, ok := mediaTypes[m.Mime]; ok {
ext = t.ext
}
return filepath.Join(s.mediaRoot(), filepath.Base(m.DocID), filepath.Base(m.ID)+ext)
}
// mediaLimit is the largest allowed file of a kind in bytes. // mediaLimit is the largest allowed file of a kind in bytes.
func (s *Server) mediaLimit(kind string) int64 { func (s *Server) mediaLimit(kind string) int64 {
@@ -65,37 +39,7 @@ func (s *Server) mediaLimit(kind string) int64 {
return int64(mb) << 20 return int64(mb) << 20
} }
// detectMedia recognises the format from the first bytes. Browsers send audio as e.g. var detectMedia = storage.DetectMedia
// "audio/webm;codecs=opus"; that name is kept when the content looks like a container we accept.
func detectMedia(data []byte, declared string) (mime, kind string, ok bool) {
sniffed := http.DetectContentType(data)
if i := strings.IndexByte(sniffed, ';'); i >= 0 {
sniffed = sniffed[:i]
}
// Go does not know WebP and M4A by name; check their signatures directly
switch {
case len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP":
sniffed = "image/webp"
case len(data) >= 12 && string(data[4:8]) == "ftyp":
sniffed = "video/mp4"
}
t, ok := mediaTypes[sniffed]
if !ok {
return "", "", false
}
if t.kind == "audio" {
if d := strings.ToLower(strings.TrimSpace(strings.SplitN(declared, ";", 2)[0])); strings.HasPrefix(d, "audio/") {
if dt, known := mediaTypes[d]; known && dt.kind == "audio" {
sniffed = d
}
} else if sniffed == "video/webm" {
sniffed = "audio/webm"
} else if sniffed == "video/mp4" {
sniffed = "audio/mp4"
}
}
return sniffed, t.kind, true
}
func (s *Server) canEditDoc(userID, docID string) bool { func (s *Server) canEditDoc(userID, docID string) bool {
role := s.store.DocRole(userID, docID) role := s.store.DocRole(userID, docID)
+1 -1
View File
@@ -123,7 +123,7 @@ func (s *Store) WriteUserBackup(userID, dir string, now time.Time) (string, erro
return "", err return "", err
} }
name := "mindsift-backup-" + now.Format(backupStampFmt) + ".zip" name := "mindsift-backup-" + now.Format(backupStampFmt) + ".zip"
return name, writeFileAtomically(filepath.Join(dir, name), func(w io.Writer) error { return WriteBackupZip(exp, w) }) return name, writeFileAtomically(filepath.Join(dir, name), func(w io.Writer) error { return s.WriteBackupZip(userID, exp, w) })
} }
func gzipFile(src, dst string) error { func gzipFile(src, dst string) error {
+39 -2
View File
@@ -32,15 +32,36 @@ func (s *Store) WriteMarkdownVault(userID string, w io.Writer) error {
} }
} }
// Images and recordings go to attachments/, notes link them relative to their folder
attachments := map[string]string{} // media ID -> path in the archive
if media, err := s.MediaOfUser(userID); err == nil {
for _, m := range media {
attachments[m.ID] = vaultAttachmentDir + "/" + m.ID + MediaExt(m.Mime)
}
}
if err := s.writeMediaFiles(zw, userID, func(m Media) string { return attachments[m.ID] }); err != nil {
return err
}
docs, err := s.GetDocuments(userID) docs, err := s.GetDocuments(userID)
if err != nil { if err != nil {
return err return err
} }
for _, d := range docs { for _, d := range docs {
name := names.file(names.dir(d.FolderPath), d.Title, "Unbenannte Notiz") dir := names.dir(d.FolderPath)
name := names.file(dir, d.Title, "Unbenannte Notiz")
up := ""
if dir != "" {
up = strings.Repeat("../", strings.Count(dir, "/")+1)
}
body := markdownFrontmatter(map[string]any{ body := markdownFrontmatter(map[string]any{
"title": d.Title, "icon": d.Icon, "tags": d.Tags, "created_at": d.CreatedAt, "updated_at": d.UpdatedAt, "title": d.Title, "icon": d.Icon, "tags": d.Tags, "created_at": d.CreatedAt, "updated_at": d.UpdatedAt,
}) + BlocksToVaultMarkdown(d.Blocks) }) + blocksToVaultMarkdown(d.Blocks, func(id string) string {
if p, ok := attachments[id]; ok {
return up + p
}
return ""
})
if err := writeZipFile(zw, name, body, parseISO(d.UpdatedAt, now)); err != nil { if err := writeZipFile(zw, name, body, parseISO(d.UpdatedAt, now)); err != nil {
return err return err
} }
@@ -164,7 +185,16 @@ func taskMarkdownLine(t domain.Thought) string {
// BlocksToVaultMarkdown turns outliner blocks into Markdown that Obsidian renders natively: // BlocksToVaultMarkdown turns outliner blocks into Markdown that Obsidian renders natively:
// top-level headings, dividers and callouts as real block elements, everything else as a nested // top-level headings, dividers and callouts as real block elements, everything else as a nested
// list (one tab per level), code as fenced blocks inside the list. // list (one tab per level), code as fenced blocks inside the list.
// vaultAttachmentDir holds images and recordings in the Markdown archive.
const vaultAttachmentDir = "attachments"
func BlocksToVaultMarkdown(blocks []domain.DocumentBlock) string { func BlocksToVaultMarkdown(blocks []domain.DocumentBlock) string {
return blocksToVaultMarkdown(blocks, nil)
}
// blocksToVaultMarkdown renders blocks as Markdown; attachment maps a media ID to its relative path
// (image and audio blocks become embeds Obsidian shows inline).
func blocksToVaultMarkdown(blocks []domain.DocumentBlock, attachment func(id string) string) string {
var sb strings.Builder var sb strings.Builder
// Children of a top-level heading/callout/code/divider become a list one level up; otherwise their // Children of a top-level heading/callout/code/divider become a list one level up; otherwise their
// tab indent would turn them into an indented code block in Markdown // tab indent would turn them into an indented code block in Markdown
@@ -178,6 +208,13 @@ func BlocksToVaultMarkdown(blocks []domain.DocumentBlock) string {
} }
indent := strings.Repeat("\t", lvl) indent := strings.Repeat("\t", lvl)
content := strings.ReplaceAll(b.Content, "\r\n", "\n") content := strings.ReplaceAll(b.Content, "\r\n", "\n")
if (b.Type == "image" || b.Type == "audio") && attachment != nil {
if ref, ok := ParseMediaContent(content); ok {
if p := attachment(ref.ID); p != "" {
content = "![" + ref.Caption + "](" + strings.ReplaceAll(p, " ", "%20") + ")"
}
}
}
// Soft line breaks continue the list item, aligned under its text // Soft line breaks continue the list item, aligned under its text
cont := func(text, prefix string) string { cont := func(text, prefix string) string {
return strings.ReplaceAll(text, "\n", "\n"+indent+prefix) return strings.ReplaceAll(text, "\n", "\n"+indent+prefix)
+235 -9
View File
@@ -7,7 +7,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/url"
"os"
"path" "path"
"path/filepath"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
@@ -23,6 +26,7 @@ const (
maxImportFileBytes = 10 << 20 // one unpacked note maxImportFileBytes = 10 << 20 // one unpacked note
maxImportUnpackedBytes = 500 << 20 // all unpacked notes together maxImportUnpackedBytes = 500 << 20 // all unpacked notes together
maxImportFiles = 20000 maxImportFiles = 20000
maxImportMediaBytes = 100 << 20 // one image or recording
) )
// ImportPlan is everything an import would create. It is built completely (and validated) before // ImportPlan is everything an import would create. It is built completely (and validated) before
@@ -35,13 +39,15 @@ type ImportPlan struct {
Inbox []importTask `json:"-"` Inbox []importTask `json:"-"`
Themes []domain.UserTheme `json:"-"` Themes []domain.UserTheme `json:"-"`
Skipped []ImportSkip `json:"skipped"` Skipped []ImportSkip `json:"skipped"`
// Images and recordings of the archive: "media:<id>" (own backup) or the path in the archive
Media map[string][]byte `json:"-"`
} }
// Only keeps the chosen sections of the plan (see NormalizeSections). // Only keeps the chosen sections of the plan (see NormalizeSections).
func (p *ImportPlan) Only(sections map[string]bool) *ImportPlan { func (p *ImportPlan) Only(sections map[string]bool) *ImportPlan {
out := &ImportPlan{Skipped: p.Skipped} out := &ImportPlan{Skipped: p.Skipped}
if sections[SectionNotes] { if sections[SectionNotes] {
out.Folders, out.Docs = p.Folders, p.Docs out.Folders, out.Docs, out.Media = p.Folders, p.Docs, p.Media
} }
if sections[SectionTasks] { if sections[SectionTasks] {
out.TaskLists = p.TaskLists out.TaskLists = p.TaskLists
@@ -79,6 +85,7 @@ type ImportSummary struct {
type importDoc struct { type importDoc struct {
folder, title, icon string folder, title, icon string
dir string // folder of the note inside the archive, for relative image links
blocks []domain.DocumentBlock blocks []domain.DocumentBlock
} }
@@ -161,7 +168,23 @@ func readZipNotes(r io.ReaderAt, size int64, plan *ImportPlan) ([]zipNote, []str
continue continue
} }
if !strings.EqualFold(path.Ext(name), ".md") { if !strings.EqualFold(path.Ext(name), ".md") {
plan.skip(name, "kein Markdown (Anhänge wie Bilder oder PDFs werden nicht importiert)") if !importMediaExt[strings.ToLower(path.Ext(name))] {
plan.skip(name, "kein Markdown und kein Bild/Audio (z. B. PDFs werden nicht importiert)")
continue
}
if f.UncompressedSize64 > maxImportMediaBytes {
plan.skip(name, "zu groß (max. 100 MB pro Bild oder Aufnahme)")
continue
}
data, err := readZipFile(f, maxImportMediaBytes)
if err != nil {
plan.skip(name, "nicht lesbar")
continue
}
if unpacked += int64(len(data)); unpacked > maxImportUnpackedBytes {
return nil, nil, "", errors.New("Archiv ist entpackt zu groß (max. 500 MB)")
}
plan.addMedia(name, data)
continue continue
} }
if f.UncompressedSize64 > maxImportFileBytes { if f.UncompressedSize64 > maxImportFileBytes {
@@ -272,8 +295,9 @@ func PlanObsidianImport(r io.ReaderAt, size int64, fileName string) (*ImportPlan
if extra := missingTags(fm.tags, body); extra != "" { if extra := missingTags(fm.tags, body); extra != "" {
blocks = append(blocks, newBlock("bullet", extra, 0)) blocks = append(blocks, newBlock("bullet", extra, 0))
} }
plan.Docs = append(plan.Docs, importDoc{folder: folder, title: title, icon: fm.icon, blocks: blocks}) plan.Docs = append(plan.Docs, importDoc{folder: folder, title: title, icon: fm.icon, dir: path.Dir(n.path), blocks: blocks})
} }
plan.reportUnusedMedia()
plan.Folders = sortedKeys(folders) plan.Folders = sortedKeys(folders)
if len(plan.Docs) == 0 { if len(plan.Docs) == 0 {
return nil, errors.New("Im Archiv wurden keine Markdown-Notizen gefunden") return nil, errors.New("Im Archiv wurden keine Markdown-Notizen gefunden")
@@ -315,9 +339,10 @@ func PlanMindSiftArchiveImport(r io.ReaderAt, size int64) (*ImportPlan, error) {
if folder != "" { if folder != "" {
folders[folder] = true folders[folder] = true
} }
plan.Docs = append(plan.Docs, importDoc{folder: folder, title: title, icon: fm.icon, blocks: MarkdownToBlocks(body)}) plan.Docs = append(plan.Docs, importDoc{folder: folder, title: title, icon: fm.icon, dir: path.Dir(n.path), blocks: MarkdownToBlocks(body)})
} }
} }
plan.reportUnusedMedia()
plan.Folders = sortedKeys(folders) plan.Folders = sortedKeys(folders)
if len(plan.Docs)+len(plan.TaskLists)+len(plan.Lists) == 0 { if len(plan.Docs)+len(plan.TaskLists)+len(plan.Lists) == 0 {
return nil, errors.New("Das Archiv enthält keine MindSift-Daten (Notizen, Aufgaben oder Listen)") return nil, errors.New("Das Archiv enthält keine MindSift-Daten (Notizen, Aufgaben oder Listen)")
@@ -328,10 +353,12 @@ func PlanMindSiftArchiveImport(r io.ReaderAt, size int64) (*ImportPlan, error) {
// BackupFileName is the JSON inside the backup ZIP. // BackupFileName is the JSON inside the backup ZIP.
const BackupFileName = "mindsift-backup.json" const BackupFileName = "mindsift-backup.json"
// WriteBackupZip streams the JSON backup compressed into a ZIP with a single file. // WriteBackupZip streams the JSON backup compressed into a ZIP, plus the images and recordings of the
func WriteBackupZip(exp *domain.FullExport, w io.Writer) error { // user's notes as media/<id><ext> (blocks point to them as media:<id>).
func (s *Store) WriteBackupZip(userID string, exp *domain.FullExport, w io.Writer) error {
zw := zip.NewWriter(w) zw := zip.NewWriter(w)
f, err := zw.CreateHeader(&zip.FileHeader{Name: BackupFileName, Method: zip.Deflate, Modified: parseISO(exp.ExportedAt, time.Now())}) modified := parseISO(exp.ExportedAt, time.Now())
f, err := zw.CreateHeader(&zip.FileHeader{Name: BackupFileName, Method: zip.Deflate, Modified: modified})
if err != nil { if err != nil {
return err return err
} }
@@ -340,9 +367,36 @@ func WriteBackupZip(exp *domain.FullExport, w io.Writer) error {
if err := enc.Encode(exp); err != nil { if err := enc.Encode(exp); err != nil {
return err return err
} }
if err := s.writeMediaFiles(zw, userID, func(m Media) string { return "media/" + m.ID + MediaExt(m.Mime) }); err != nil {
return err
}
return zw.Close() return zw.Close()
} }
// writeMediaFiles adds the user's media files to an archive (already compressed formats: stored as is).
// Files missing on disk are left out.
func (s *Store) writeMediaFiles(zw *zip.Writer, userID string, name func(Media) string) error {
media, err := s.MediaOfUser(userID)
if err != nil {
return err
}
for _, m := range media {
src, err := os.Open(s.MediaFilePath(&m))
if err != nil {
continue
}
dst, err := zw.CreateHeader(&zip.FileHeader{Name: name(m), Method: zip.Store, Modified: parseISO(m.CreatedAt, time.Now())})
if err == nil {
_, err = io.Copy(dst, src)
}
src.Close()
if err != nil {
return err
}
}
return nil
}
// PlanMindSiftImport picks the format of MindSift's own data: plain JSON backup, the backup ZIP // PlanMindSiftImport picks the format of MindSift's own data: plain JSON backup, the backup ZIP
// (one JSON inside) or the Markdown archive. // (one JSON inside) or the Markdown archive.
func PlanMindSiftImport(data []byte, fileName string) (*ImportPlan, error) { func PlanMindSiftImport(data []byte, fileName string) (*ImportPlan, error) {
@@ -367,6 +421,22 @@ func PlanMindSiftImport(data []byte, fileName string) (*ImportPlan, error) {
if backup == nil { if backup == nil {
return PlanMindSiftArchiveImport(bytes.NewReader(data), int64(len(data))) return PlanMindSiftArchiveImport(bytes.NewReader(data), int64(len(data)))
} }
// Images and recordings next to the JSON: media/<id><ext>
media := map[string][]byte{}
var mediaBytes int64
for _, f := range zr.File {
name := cleanArchivePath(f.Name)
if !strings.HasPrefix(name, "media/") || f.FileInfo().IsDir() || f.UncompressedSize64 > maxImportMediaBytes {
continue
}
if mediaBytes += int64(f.UncompressedSize64); mediaBytes > maxImportUnpackedBytes {
return nil, errors.New("Backup ist entpackt zu groß (max. 500 MB)")
}
if b, err := readZipFile(f, maxImportMediaBytes); err == nil {
base := path.Base(name)
media["media:"+strings.TrimSuffix(base, path.Ext(base))] = b
}
}
if backup.UncompressedSize64 > maxImportUnpackedBytes { if backup.UncompressedSize64 > maxImportUnpackedBytes {
return nil, errors.New("Backup ist entpackt zu groß (max. 500 MB)") return nil, errors.New("Backup ist entpackt zu groß (max. 500 MB)")
} }
@@ -379,7 +449,14 @@ func PlanMindSiftImport(data []byte, fileName string) (*ImportPlan, error) {
if err != nil || len(raw) > maxImportUnpackedBytes { if err != nil || len(raw) > maxImportUnpackedBytes {
return nil, errors.New("Backup im Archiv ist nicht lesbar oder zu groß") return nil, errors.New("Backup im Archiv ist nicht lesbar oder zu groß")
} }
return PlanMindSiftJSONImport(raw) plan, err := PlanMindSiftJSONImport(raw)
if err != nil {
return nil, err
}
if len(media) > 0 {
plan.Media = media
}
return plan, nil
} }
// PlanMindSiftJSONImport reads the JSON backup ("Backup herunterladen"). Notes shared with the // PlanMindSiftJSONImport reads the JSON backup ("Backup herunterladen"). Notes shared with the
@@ -461,7 +538,12 @@ func (s *Store) ApplyImport(userID string, plan *ImportPlan) (ImportSummary, err
if icon == "" { if icon == "" {
icon = "📄" icon = "📄"
} }
if _, err := s.CreateDocument(userID, "", title, icon, d.folder, d.blocks, nil, false); err != nil { blocks, files := linkImportMedia(d, plan.Media, nil)
doc, err := s.CreateDocument(userID, "", title, icon, d.folder, blocks, nil, false)
if err != nil {
return plan.Summary(false), fmt.Errorf("Notiz %q: %w", title, err)
}
if err := s.storeImportMedia(userID, doc.ID, files); err != nil {
return plan.Summary(false), fmt.Errorf("Notiz %q: %w", title, err) return plan.Summary(false), fmt.Errorf("Notiz %q: %w", title, err)
} }
} }
@@ -902,3 +984,147 @@ func sortedKeys(m map[string]bool) []string {
sort.Strings(out) sort.Strings(out)
return out return out
} }
// --- Images and recordings ----------------------------------------------------------------------------
var importMediaExt = map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".webp": true,
".webm": true, ".ogg": true, ".oga": true, ".mp3": true, ".wav": true, ".m4a": true, ".aac": true}
// reportUnusedMedia lists files no note shows (or that are no valid image/recording) as skipped in the
// preview and drops them from the plan.
func (p *ImportPlan) reportUnusedMedia() {
if len(p.Media) == 0 {
return
}
used := map[string]bool{}
for _, d := range p.Docs {
linkImportMedia(d, p.Media, used)
}
for _, k := range sortedKeys(keySet(p.Media)) {
if used[k] {
continue
}
if _, _, ok := DetectMedia(p.Media[k], ""); !ok {
p.skip(k, "kein gültiges Bild bzw. keine gültige Aufnahme")
} else {
p.skip(k, "wird in keiner Notiz angezeigt")
}
delete(p.Media, k)
}
}
func keySet(m map[string][]byte) map[string]bool {
out := make(map[string]bool, len(m))
for k := range m {
out[k] = true
}
return out
}
func (p *ImportPlan) addMedia(name string, data []byte) {
if p.Media == nil {
p.Media = map[string][]byte{}
}
p.Media[name] = data
}
func readZipFile(f *zip.File, limit int64) ([]byte, error) {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
data, err := io.ReadAll(io.LimitReader(rc, limit+1))
if err != nil || int64(len(data)) > limit {
return nil, errors.New("nicht lesbar oder zu groß")
}
return data, nil
}
type importMediaFile struct {
id, kind, mime string
data []byte
}
var (
mdEmbedRe = regexp.MustCompile(`^!\[([^\]]*)\]\(<?([^)>]+?)>?(?:\s+"[^"]*")?\)$`)
wikiEmbedRe = regexp.MustCompile(`^!\[\[([^|\]]+)(?:\|([^\]]*))?\]\]$`)
sizeOnlyRe = regexp.MustCompile(`^\d+(x\d+)?$`)
)
// linkImportMedia turns blocks that only show a file of the archive into image/audio blocks with new
// file IDs: media:<id> of an own backup, Markdown images relative to the note, Obsidian ![[file]]
// embeds (found by name anywhere in the archive). Files without such a block are not imported.
func linkImportMedia(d importDoc, media map[string][]byte, used map[string]bool) ([]domain.DocumentBlock, []importMediaFile) {
if len(media) == 0 {
return d.blocks, nil
}
blocks := make([]domain.DocumentBlock, len(d.blocks))
copy(blocks, d.blocks)
byKey := map[string]importMediaFile{} // one copy per note, even when shown twice
var files []importMediaFile
for i, b := range blocks {
content := strings.TrimSpace(b.Content)
key, caption := "", ""
if ref, ok := ParseMediaContent(content); ok {
key, caption = "media:"+ref.ID, ref.Caption
} else if m := mdEmbedRe.FindStringSubmatch(content); m != nil {
target := m[2]
if u, err := url.PathUnescape(target); err == nil {
target = u
}
key, caption = path.Clean(path.Join(d.dir, target)), m[1]
} else if m := wikiEmbedRe.FindStringSubmatch(content); m != nil {
name := strings.TrimSpace(m[1])
for k := range media {
if path.Base(k) == name || k == name {
key = k
break
}
}
if !sizeOnlyRe.MatchString(m[2]) {
caption = m[2]
}
}
data, ok := media[key]
if key == "" || !ok {
continue
}
f, seen := byKey[key]
if !seen {
if used != nil {
used[key] = true
}
mime, kind, ok := DetectMedia(data, "")
if !ok {
continue
}
f = importMediaFile{id: domain.NewID("med"), kind: kind, mime: mime, data: data}
byKey[key] = f
files = append(files, f)
}
blocks[i].Type = f.kind
blocks[i].Content = MediaContent(f.kind, f.id, caption)
}
return blocks, files
}
func (s *Store) storeImportMedia(userID, docID string, files []importMediaFile) error {
if len(files) == 0 || s.mediaRoot == "" {
return nil
}
for _, f := range files {
m, err := s.createMediaWithID(f.id, docID, userID, f.kind, f.mime, int64(len(f.data)), "")
if err != nil {
return err
}
dst := s.MediaFilePath(m)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if err := os.WriteFile(dst, f.data, 0o644); err != nil {
return err
}
}
return nil
}
+1 -1
View File
@@ -204,7 +204,7 @@ func TestImportBackupZip(t *testing.T) {
seedAnna(t, store, anna) seedAnna(t, store, anna)
exp, _ := store.ExportUserData(anna.ID) exp, _ := store.ExportUserData(anna.ID)
var buf bytes.Buffer var buf bytes.Buffer
if err := WriteBackupZip(exp, &buf); err != nil { if err := store.WriteBackupZip(anna.ID, exp, &buf); err != nil {
t.Fatalf("WriteBackupZip: %v", err) t.Fatalf("WriteBackupZip: %v", err)
} }
if buf.Len() == 0 { if buf.Len() == 0 {
+119 -1
View File
@@ -3,6 +3,10 @@ package storage
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"net/http"
"path/filepath"
"regexp"
"strings"
"time" "time"
"github.com/sandrodach/mindsift/internal/domain" "github.com/sandrodach/mindsift/internal/domain"
@@ -26,6 +30,72 @@ type Media struct {
var ErrMediaNotFound = errors.New("Datei nicht gefunden") var ErrMediaNotFound = errors.New("Datei nicht gefunden")
// Accepted formats (detected from the content, not from the name): file extension and kind
var mediaTypes = map[string]struct{ ext, kind string }{
"image/jpeg": {".jpg", "image"},
"image/png": {".png", "image"},
"image/gif": {".gif", "image"},
"image/webp": {".webp", "image"},
"audio/webm": {".webm", "audio"},
"video/webm": {".webm", "audio"}, // Chrome's audio-only recordings are detected as video/webm
"audio/ogg": {".ogg", "audio"},
"application/ogg": {".ogg", "audio"},
"audio/mpeg": {".mp3", "audio"},
"audio/wave": {".wav", "audio"},
"audio/wav": {".wav", "audio"},
"audio/mp4": {".m4a", "audio"},
"video/mp4": {".m4a", "audio"}, // Safari records MP4/AAC
"audio/aac": {".aac", "audio"},
}
// DetectMedia recognises the format from the first bytes. Browsers send audio as e.g.
// "audio/webm;codecs=opus"; that name is kept when the content looks like a container we accept.
func DetectMedia(data []byte, declared string) (mime, kind string, ok bool) {
sniffed := http.DetectContentType(data)
if i := strings.IndexByte(sniffed, ';'); i >= 0 {
sniffed = sniffed[:i]
}
// Go does not know WebP and M4A by name; check their signatures directly
switch {
case len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP":
sniffed = "image/webp"
case len(data) >= 12 && string(data[4:8]) == "ftyp":
sniffed = "video/mp4"
}
t, ok := mediaTypes[sniffed]
if !ok {
return "", "", false
}
if t.kind == "audio" {
if d := strings.ToLower(strings.TrimSpace(strings.SplitN(declared, ";", 2)[0])); strings.HasPrefix(d, "audio/") {
if dt, known := mediaTypes[d]; known && dt.kind == "audio" {
sniffed = d
}
} else if sniffed == "video/webm" {
sniffed = "audio/webm"
} else if sniffed == "video/mp4" {
sniffed = "audio/mp4"
}
}
return sniffed, t.kind, true
}
// MediaExt is the file extension for a stored format.
func MediaExt(mime string) string {
if t, ok := mediaTypes[mime]; ok {
return t.ext
}
return ".bin"
}
// MediaRoot is the folder with all media files ("" for in-memory databases).
func (s *Store) MediaRoot() string { return s.mediaRoot }
// MediaFilePath is where the file of m lies.
func (s *Store) MediaFilePath(m *Media) string {
return filepath.Join(s.mediaRoot, filepath.Base(m.DocID), filepath.Base(m.ID)+MediaExt(m.Mime))
}
func (s *Store) migrateMedia() error { func (s *Store) migrateMedia() error {
return s.execInTx( return s.execInTx(
stmt(`CREATE TABLE IF NOT EXISTS media ( stmt(`CREATE TABLE IF NOT EXISTS media (
@@ -44,7 +114,11 @@ func (s *Store) migrateMedia() error {
} }
func (s *Store) CreateMedia(docID, ownerID, kind, mime string, size int64, sourceURL string) (*Media, error) { func (s *Store) CreateMedia(docID, ownerID, kind, mime string, size int64, sourceURL string) (*Media, error) {
m := &Media{ID: domain.NewID("med"), DocID: docID, OwnerID: ownerID, Kind: kind, Mime: mime, Size: size, return s.createMediaWithID(domain.NewID("med"), docID, ownerID, kind, mime, size, sourceURL)
}
func (s *Store) createMediaWithID(id, docID, ownerID, kind, mime string, size int64, sourceURL string) (*Media, error) {
m := &Media{ID: id, DocID: docID, OwnerID: ownerID, Kind: kind, Mime: mime, Size: size,
SourceURL: sourceURL, CreatedAt: nowUTC()} SourceURL: sourceURL, CreatedAt: nowUTC()}
_, err := s.db.Exec(`INSERT INTO media (id, doc_id, owner_id, kind, mime, size, source_url, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, _, err := s.db.Exec(`INSERT INTO media (id, doc_id, owner_id, kind, mime, size, source_url, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
m.ID, m.DocID, m.OwnerID, m.Kind, m.Mime, m.Size, m.SourceURL, m.CreatedAt) m.ID, m.DocID, m.OwnerID, m.Kind, m.Mime, m.Size, m.SourceURL, m.CreatedAt)
@@ -117,3 +191,47 @@ func (s *Store) UnreferencedMedia(olderThan time.Time) ([]Media, error) {
} }
return out, rows.Err() return out, rows.Err()
} }
// MediaOfUser lists the files shown in the user's own notes (for backups and exports).
func (s *Store) MediaOfUser(userID string) ([]Media, error) {
rows, err := s.db.Query(`SELECT DISTINCT m.id, m.doc_id, m.owner_id, m.kind, m.mime, m.size, m.source_url, m.created_at
FROM media m JOIN blocks b ON instr(b.content, 'media:' || m.id) > 0
JOIN documents d ON d.id = b.document_id WHERE d.user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Media
for rows.Next() {
var m Media
if rows.Scan(&m.ID, &m.DocID, &m.OwnerID, &m.Kind, &m.Mime, &m.Size, &m.SourceURL, &m.CreatedAt) == nil {
out = append(out, m)
}
}
return out, rows.Err()
}
// MediaRef is a block's pointer to a file: ![caption](media:id) for images, [caption](media:id) for audio.
type MediaRef struct{ ID, Caption string }
var mediaRefRe = regexp.MustCompile(`^!?\[((?:[^\]\\]|\\.)*)\]\(media:(med_[0-9a-f]+)\)$`)
var mediaRefEsc = regexp.MustCompile(`\\(.)`)
func ParseMediaContent(content string) (MediaRef, bool) {
m := mediaRefRe.FindStringSubmatch(strings.TrimSpace(content))
if m == nil {
return MediaRef{}, false
}
return MediaRef{ID: m[2], Caption: mediaRefEsc.ReplaceAllString(m[1], "$1")}, true
}
// MediaContent builds a block's content for a file (same format as the browser, lib/media.ts).
func MediaContent(kind, id, caption string) string {
caption = strings.Join(strings.Fields(strings.ReplaceAll(caption, "\n", " ")), " ")
caption = strings.NewReplacer(`\`, `\\`, `]`, `\]`).Replace(caption)
prefix := ""
if kind == "image" {
prefix = "!"
}
return prefix + "[" + caption + "](media:" + id + ")"
}
+193
View File
@@ -0,0 +1,193 @@
package storage
import (
"archive/zip"
"bytes"
"image"
"image/png"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/sandrodach/mindsift/internal/domain"
)
func pngBytes(t *testing.T, w int) []byte {
t.Helper()
var buf bytes.Buffer
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, w, 2))); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// storeTestMedia creates a media row with its file and returns it.
func storeTestMedia(t *testing.T, store *Store, docID, ownerID string, data []byte) *Media {
t.Helper()
mime, kind, ok := DetectMedia(data, "")
if !ok {
t.Fatal("test data not recognised")
}
m, err := store.CreateMedia(docID, ownerID, kind, mime, int64(len(data)), "")
if err != nil {
t.Fatal(err)
}
_ = os.MkdirAll(filepath.Dir(store.MediaFilePath(m)), 0o755)
if err := os.WriteFile(store.MediaFilePath(m), data, 0o644); err != nil {
t.Fatal(err)
}
return m
}
// mediaBlocksOf returns the image/audio blocks of the user's notes and the bytes of their files.
func mediaBlocksOf(t *testing.T, store *Store, userID string) map[string][]byte {
t.Helper()
docs, _ := store.GetDocuments(userID)
out := map[string][]byte{}
for _, d := range docs {
for _, b := range d.Blocks {
if b.Type != "image" && b.Type != "audio" {
continue
}
ref, ok := ParseMediaContent(b.Content)
if !ok {
t.Fatalf("block content is no media reference: %q", b.Content)
}
m, err := store.GetMedia(ref.ID)
if err != nil || m.OwnerID != userID || m.DocID != d.ID {
t.Fatalf("media row of %s: %+v %v", ref.ID, m, err)
}
data, err := os.ReadFile(store.MediaFilePath(m))
if err != nil {
t.Fatal(err)
}
out[ref.Caption] = data
}
}
return out
}
func TestBackupZipCarriesMedia(t *testing.T) {
store, anna, ben := newImportStore(t)
doc, _ := store.CreateDocument(anna.ID, "", "Urlaub", "🏖️", "Reisen", nil, nil, false)
img := pngBytes(t, 7)
m := storeTestMedia(t, store, doc.ID, anna.ID, img)
_, _ = store.CreateDocument(anna.ID, "", "Urlaub", "🏖️", "Reisen", []domain.DocumentBlock{
{ID: "blk_t", Type: "bullet", Content: "Strand"},
{ID: "blk_i", Type: "image", Content: MediaContent("image", m.ID, "Sonnenuntergang [Süd]")},
}, nil, false)
exp, _ := store.ExportUserData(anna.ID)
var buf bytes.Buffer
if err := store.WriteBackupZip(anna.ID, exp, &buf); err != nil {
t.Fatal(err)
}
zr, _ := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
found := false
for _, f := range zr.File {
found = found || f.Name == "media/"+m.ID+".png"
}
if !found {
t.Fatal("backup ZIP must contain the image")
}
plan, err := PlanMindSiftImport(buf.Bytes(), "backup.zip")
if err != nil {
t.Fatal(err)
}
if _, err := store.ApplyImport(ben.ID, plan); err != nil {
t.Fatal(err)
}
got := mediaBlocksOf(t, store, ben.ID)
if !bytes.Equal(got["Sonnenuntergang [Süd]"], img) {
t.Fatalf("restored image missing or different: %v", keysOf(got))
}
}
func TestMarkdownVaultCarriesMedia(t *testing.T) {
store, anna, ben := newImportStore(t)
doc, _ := store.CreateDocument(anna.ID, "", "Platzhalter", "", "", nil, nil, false)
img := pngBytes(t, 5)
m := storeTestMedia(t, store, doc.ID, anna.ID, img)
_, _ = store.CreateDocument(anna.ID, "", "Plan", "", "Projekte/Haus", []domain.DocumentBlock{
{ID: "blk_p", Type: "image", Content: MediaContent("image", m.ID, "Grundriss")},
}, nil, false)
var buf bytes.Buffer
if err := store.WriteMarkdownVault(anna.ID, &buf); err != nil {
t.Fatal(err)
}
zr, _ := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
var note string
hasFile := false
for _, f := range zr.File {
if f.Name == "attachments/"+m.ID+".png" {
hasFile = true
}
if f.Name == "Projekte/Haus/Plan.md" {
rc, _ := f.Open()
b, _ := io.ReadAll(rc)
rc.Close()
note = string(b)
}
}
if !hasFile || !strings.Contains(note, "![Grundriss](../../attachments/"+m.ID+".png)") {
t.Fatalf("vault: attachment %v, note:\n%s", hasFile, note)
}
plan, err := PlanMindSiftImport(buf.Bytes(), "vault.zip")
if err != nil {
t.Fatal(err)
}
if _, err := store.ApplyImport(ben.ID, plan); err != nil {
t.Fatal(err)
}
if got := mediaBlocksOf(t, store, ben.ID); !bytes.Equal(got["Grundriss"], img) {
t.Fatalf("re-imported image missing: %v", keysOf(got))
}
}
func TestObsidianVaultImagesAreImported(t *testing.T) {
store, _, ben := newImportStore(t)
img1, img2 := pngBytes(t, 3), pngBytes(t, 4)
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
add := func(name string, data []byte) {
w, _ := zw.Create(name)
_, _ = w.Write(data)
}
add("Vault/Reisen/Rom.md", []byte("# Rom\n\n- ![[kolosseum.png|400]]\n- ![Brunnen](bilder/trevi%20brunnen.png)\n- Text mit ![[kolosseum.png]] mittendrin\n"))
add("Vault/Anhänge/kolosseum.png", img1)
add("Vault/Reisen/bilder/trevi brunnen.png", img2)
add("Vault/Anhänge/handbuch.pdf", []byte("%PDF-1.4"))
_ = zw.Close()
plan, err := PlanObsidianImport(bytes.NewReader(buf.Bytes()), int64(buf.Len()), "Vault.zip")
if err != nil {
t.Fatal(err)
}
if _, err := store.ApplyImport(ben.ID, plan); err != nil {
t.Fatal(err)
}
got := mediaBlocksOf(t, store, ben.ID)
if !bytes.Equal(got[""], img1) || !bytes.Equal(got["Brunnen"], img2) || len(got) != 2 {
t.Fatalf("obsidian images: %v", keysOf(got))
}
skippedPDF := false
for _, s := range plan.Skipped {
skippedPDF = skippedPDF || strings.HasSuffix(s.Path, "handbuch.pdf")
}
if !skippedPDF {
t.Fatal("PDF must be reported as skipped")
}
}
func keysOf(m map[string][]byte) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
+5
View File
@@ -19,6 +19,8 @@ import (
type Store struct { type Store struct {
db *sql.DB db *sql.DB
initMu sync.Mutex initMu sync.Mutex
// mediaRoot holds the files of images and audio in notes ($DATA_DIR/media), see media.go
mediaRoot string
// OnTaskCreated is told about tasks the store creates by itself (next occurrence of a repeating // OnTaskCreated is told about tasks the store creates by itself (next occurrence of a repeating
// task), so the server can push them to connected clients. // task), so the server can push them to connected clients.
@@ -50,6 +52,9 @@ func NewStore(dbPath string) (*Store, error) {
db.SetMaxIdleConns(4) db.SetMaxIdleConns(4)
store := &Store{db: db} store := &Store{db: db}
if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file:") {
store.mediaRoot = filepath.Join(filepath.Dir(dbPath), "media")
}
if err := store.migrate(); err != nil { if err := store.migrate(); err != nil {
return nil, fmt.Errorf("failed to run migrations: %w", err) return nil, fmt.Errorf("failed to run migrations: %w", err)
} }