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 -1
View File
@@ -156,7 +156,7 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("format") == "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")))
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)
}
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
// 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"},
}
func (s *Server) mediaRoot() string { return s.store.MediaRoot() }
func (s *Server) mediaRoot() string {
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)
}
func (s *Server) mediaPath(m *storage.Media) string { return s.store.MediaFilePath(m) }
// mediaLimit is the largest allowed file of a kind in bytes.
func (s *Server) mediaLimit(kind string) int64 {
@@ -65,37 +39,7 @@ func (s *Server) mediaLimit(kind string) int64 {
return int64(mb) << 20
}
// 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
}
var detectMedia = storage.DetectMedia
func (s *Server) canEditDoc(userID, docID string) bool {
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
}
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 {
+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)
if err != nil {
return err
}
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{
"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 {
return err
}
@@ -164,7 +185,16 @@ func taskMarkdownLine(t domain.Thought) string {
// 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
// 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 {
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
// 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
@@ -178,6 +208,13 @@ func BlocksToVaultMarkdown(blocks []domain.DocumentBlock) string {
}
indent := strings.Repeat("\t", lvl)
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
cont := func(text, prefix string) string {
return strings.ReplaceAll(text, "\n", "\n"+indent+prefix)
+235 -9
View File
@@ -7,7 +7,10 @@ import (
"errors"
"fmt"
"io"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
@@ -23,6 +26,7 @@ const (
maxImportFileBytes = 10 << 20 // one unpacked note
maxImportUnpackedBytes = 500 << 20 // all unpacked notes together
maxImportFiles = 20000
maxImportMediaBytes = 100 << 20 // one image or recording
)
// ImportPlan is everything an import would create. It is built completely (and validated) before
@@ -35,13 +39,15 @@ type ImportPlan struct {
Inbox []importTask `json:"-"`
Themes []domain.UserTheme `json:"-"`
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).
func (p *ImportPlan) Only(sections map[string]bool) *ImportPlan {
out := &ImportPlan{Skipped: p.Skipped}
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] {
out.TaskLists = p.TaskLists
@@ -79,6 +85,7 @@ type ImportSummary struct {
type importDoc struct {
folder, title, icon string
dir string // folder of the note inside the archive, for relative image links
blocks []domain.DocumentBlock
}
@@ -161,7 +168,23 @@ func readZipNotes(r io.ReaderAt, size int64, plan *ImportPlan) ([]zipNote, []str
continue
}
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
}
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 != "" {
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)
if len(plan.Docs) == 0 {
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 != "" {
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)
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)")
@@ -328,10 +353,12 @@ func PlanMindSiftArchiveImport(r io.ReaderAt, size int64) (*ImportPlan, error) {
// BackupFileName is the JSON inside the backup ZIP.
const BackupFileName = "mindsift-backup.json"
// WriteBackupZip streams the JSON backup compressed into a ZIP with a single file.
func WriteBackupZip(exp *domain.FullExport, w io.Writer) error {
// WriteBackupZip streams the JSON backup compressed into a ZIP, plus the images and recordings of the
// 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)
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 {
return err
}
@@ -340,9 +367,36 @@ func WriteBackupZip(exp *domain.FullExport, w io.Writer) error {
if err := enc.Encode(exp); err != nil {
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()
}
// 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
// (one JSON inside) or the Markdown archive.
func PlanMindSiftImport(data []byte, fileName string) (*ImportPlan, error) {
@@ -367,6 +421,22 @@ func PlanMindSiftImport(data []byte, fileName string) (*ImportPlan, error) {
if backup == nil {
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 {
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 {
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
@@ -461,7 +538,12 @@ func (s *Store) ApplyImport(userID string, plan *ImportPlan) (ImportSummary, err
if 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)
}
}
@@ -902,3 +984,147 @@ func sortedKeys(m map[string]bool) []string {
sort.Strings(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)
exp, _ := store.ExportUserData(anna.ID)
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)
}
if buf.Len() == 0 {
+119 -1
View File
@@ -3,6 +3,10 @@ package storage
import (
"database/sql"
"errors"
"net/http"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/sandrodach/mindsift/internal/domain"
@@ -26,6 +30,72 @@ type Media struct {
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 {
return s.execInTx(
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) {
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()}
_, 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)
@@ -117,3 +191,47 @@ func (s *Store) UnreferencedMedia(olderThan time.Time) ([]Media, error) {
}
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 {
db *sql.DB
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
// task), so the server can push them to connected clients.
@@ -50,6 +52,9 @@ func NewStore(dbPath string) (*Store, error) {
db.SetMaxIdleConns(4)
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 {
return nil, fmt.Errorf("failed to run migrations: %w", err)
}