feat(media): images and recordings in backups, exports and imports (M6)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
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 (); re-importing turns them back into image/audio blocks. - Obsidian import takes images and audio a note embeds on its own (![[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:
1 parent
3f2bc96732
commit
1f63e08a73
13 files changed
+608
-82
No files matched your search
@@ -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]]`, ``).
|
||||||
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 ``; 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 ``; 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 `` 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 ``); 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).
|
||||||
|
|
||||||
|
|||||||
@@ -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"></code>), werden übernommen; andere Anhänge wie PDFs nicht.
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 = " + ")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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:  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 + ")"
|
||||||
|
}
|
||||||
@@ -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, "") {
|
||||||
|
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- \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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user