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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user