Files
MindSift/internal/storage/import_markdown.go
T
sandrodachandClaude Opus 5.5 b136e5dfad
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m47s
feat(workspace): sliding sidebar in focus mode, calmer workspace bar, smoother journal
- focus mode: folders slide in from the left (button, title click, or the
  left edge on wide screens); picking a note keeps focus mode
- workspace bar: borderless live/shared/save pills in one style, divider
  before the note actions; footer without top line
- journal panel and calendar open with an animation, the view fades in
- paste and markdown import nest a list under the paragraph introducing it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:04:30 +01:00

1151 lines
35 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package storage
import (
"archive/zip"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"unicode/utf8"
"github.com/sandrodach/mindsift/internal/domain"
)
// Import limits: they keep a crafted archive (zip bomb, millions of files) from exhausting the server.
const (
MaxImportUploadBytes = 100 << 20 // uploaded file
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
// anything is written, so the preview shows exactly what the import does.
type ImportPlan struct {
Folders []string `json:"-"`
Docs []importDoc `json:"-"`
TaskLists []importTaskList `json:"-"`
Lists []importList `json:"-"`
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, out.Media = p.Folders, p.Docs, p.Media
}
if sections[SectionTasks] {
out.TaskLists = p.TaskLists
}
if sections[SectionLists] {
out.Lists = p.Lists
}
if sections[SectionInbox] {
out.Inbox = p.Inbox
}
if sections[SectionThemes] {
out.Themes = p.Themes
}
return out
}
type ImportSkip struct {
Path string `json:"path"`
Reason string `json:"reason"`
}
// ImportSummary is what the API returns for a preview and after the import.
type ImportSummary struct {
Folders int `json:"folders"`
Notes int `json:"notes"`
TaskLists int `json:"taskLists"`
Tasks int `json:"tasks"`
Lists int `json:"lists"`
ListItems int `json:"listItems"`
Inbox int `json:"inbox"`
Themes int `json:"themes"`
Skipped []ImportSkip `json:"skipped"`
Applied bool `json:"applied"`
}
type importDoc struct {
folder, title, icon string
dir string // folder of the note inside the archive, for relative image links
blocks []domain.DocumentBlock
}
type importTask struct {
content, priority, due, notes, recurrence string
done bool
subtasks []domain.SubTask
}
type importTaskList struct {
title, icon string
tasks []importTask
}
type importList struct {
title, icon string
items []importItem
}
type importItem struct {
content, category, quantity string
done bool
}
func (p *ImportPlan) skip(path, reason string) {
p.Skipped = append(p.Skipped, ImportSkip{Path: path, Reason: reason})
}
func (p *ImportPlan) Summary(applied bool) ImportSummary {
sum := ImportSummary{Folders: len(p.Folders), Notes: len(p.Docs), Lists: len(p.Lists), Inbox: len(p.Inbox), Themes: len(p.Themes), Skipped: p.Skipped, Applied: applied}
for _, tl := range p.TaskLists {
if tl.title != "" {
sum.TaskLists++
}
sum.Tasks += len(tl.tasks)
}
for _, l := range p.Lists {
sum.ListItems += len(l.items)
}
if sum.Skipped == nil {
sum.Skipped = []ImportSkip{}
}
return sum
}
// --- Reading archives -------------------------------------------------------------------------------
type zipNote struct {
path string // inside the archive, "/"-separated, without a leading vault folder
text string
}
// readZipNotes returns the Markdown files of an archive (other files are reported as skipped). A
// single top-level folder, as Obsidian and most zip tools create, is stripped and returned as name.
func readZipNotes(r io.ReaderAt, size int64, plan *ImportPlan) ([]zipNote, []string, string, error) {
zr, err := zip.NewReader(r, size)
if err != nil {
return nil, nil, "", errors.New("Die Datei ist kein gültiges ZIP-Archiv")
}
if len(zr.File) > maxImportFiles {
return nil, nil, "", fmt.Errorf("Zu viele Dateien im Archiv (max. %d)", maxImportFiles)
}
names := make([]string, 0, len(zr.File))
for _, f := range zr.File {
names = append(names, cleanArchivePath(f.Name))
}
prefix := commonTopFolder(names)
var notes []zipNote
var dirs []string
var unpacked int64
for i, f := range zr.File {
name := strings.TrimPrefix(names[i], prefix)
if name == "" || hiddenPath(name) {
continue
}
if f.FileInfo().IsDir() {
dirs = append(dirs, strings.TrimSuffix(name, "/"))
continue
}
if !strings.EqualFold(path.Ext(name), ".md") {
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 {
plan.skip(name, "zu groß (max. 10 MB pro Notiz)")
continue
}
rc, err := f.Open()
if err != nil {
plan.skip(name, "nicht lesbar")
continue
}
data, err := io.ReadAll(io.LimitReader(rc, maxImportFileBytes+1))
rc.Close()
if err != nil || len(data) > maxImportFileBytes {
plan.skip(name, "nicht lesbar oder zu groß")
continue
}
if unpacked += int64(len(data)); unpacked > maxImportUnpackedBytes {
return nil, nil, "", errors.New("Archiv ist entpackt zu groß (max. 500 MB)")
}
if !utf8.Valid(data) {
plan.skip(name, "keine UTF-8-Textdatei")
continue
}
notes = append(notes, zipNote{path: name, text: strings.ReplaceAll(string(data), "\r\n", "\n")})
}
sort.Slice(notes, func(i, j int) bool { return notes[i].path < notes[j].path })
return notes, dirs, strings.TrimSuffix(prefix, "/"), nil
}
// cleanArchivePath normalises an entry name and removes anything that could escape (.., absolute paths).
func cleanArchivePath(name string) string {
name = strings.ReplaceAll(name, "\\", "/")
parts := make([]string, 0)
for _, p := range strings.Split(name, "/") {
if p == "" || p == "." || p == ".." {
continue
}
parts = append(parts, p)
}
out := strings.Join(parts, "/")
if strings.HasSuffix(name, "/") && out != "" {
out += "/"
}
return out
}
func commonTopFolder(names []string) string {
top := ""
for _, n := range names {
if n == "" || hiddenPath(n) {
continue
}
i := strings.Index(n, "/")
if i < 0 {
return "" // a file at the root: no common folder
}
if top == "" {
top = n[:i+1]
} else if top != n[:i+1] {
return ""
}
}
return top
}
// hiddenPath skips Obsidian's settings, trash and OS junk.
func hiddenPath(p string) bool {
for _, part := range strings.Split(p, "/") {
if strings.HasPrefix(part, ".") || part == "__MACOSX" {
return true
}
}
return false
}
// --- Planning ---------------------------------------------------------------------------------------
// PlanObsidianImport reads a vault ZIP. Everything lands in a top folder named after the vault, so an
// import never mixes into existing notes. Checkboxes stay checkboxes in their note.
func PlanObsidianImport(r io.ReaderAt, size int64, fileName string) (*ImportPlan, error) {
plan := &ImportPlan{}
notes, dirs, vault, err := readZipNotes(r, size, plan)
if err != nil {
return nil, err
}
if vault == "" {
vault = strings.TrimSuffix(path.Base(fileName), path.Ext(fileName))
}
root := segment(vault)
if root == "" {
root = "Obsidian-Import"
}
folders := map[string]bool{root: true}
for _, d := range dirs {
folders[path.Join(root, folderPathOf(d+"/x"))] = true
}
for _, n := range notes {
fm, body := splitFrontmatter(n.text)
folder := path.Join(root, folderPathOf(n.path))
folders[folder] = true
title := fm.title
if title == "" {
title = strings.TrimSuffix(path.Base(n.path), path.Ext(n.path))
}
blocks := MarkdownToBlocks(body)
// Front matter tags become hashtags, since MindSift reads tags from the text
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, 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")
}
return plan, nil
}
// PlanMindSiftArchiveImport reads a ZIP created by "Markdown-Archiv herunterladen": notes go back to
// their folders, Aufgaben/ back into task lists, Listen/ back into lists.
func PlanMindSiftArchiveImport(r io.ReaderAt, size int64) (*ImportPlan, error) {
plan := &ImportPlan{}
notes, dirs, _, err := readZipNotes(r, size, plan)
if err != nil {
return nil, err
}
folders := map[string]bool{}
for _, d := range dirs {
if top := strings.SplitN(d, "/", 2)[0]; top != "Aufgaben" && top != "Listen" {
folders[folderPathOf(d+"/x")] = true
}
}
for _, n := range notes {
fm, body := splitFrontmatter(n.text)
title := fm.title
if title == "" {
title = strings.TrimSuffix(path.Base(n.path), path.Ext(n.path))
}
switch folderPathOf(n.path) {
case "Aufgaben":
tl := importTaskList{title: title, icon: fm.icon, tasks: parseTaskFile(body)}
if title == "Ohne Liste" {
tl.title = ""
}
plan.TaskLists = append(plan.TaskLists, tl)
case "Listen":
plan.Lists = append(plan.Lists, importList{title: title, icon: fm.icon, items: parseListFile(body)})
default:
folder := folderPathOf(n.path)
if folder != "" {
folders[folder] = true
}
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)")
}
return plan, nil
}
// BackupFileName is the JSON inside the backup ZIP.
const BackupFileName = "mindsift-backup.json"
// 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)
modified := parseISO(exp.ExportedAt, time.Now())
f, err := zw.CreateHeader(&zip.FileHeader{Name: BackupFileName, Method: zip.Deflate, Modified: modified})
if err != nil {
return err
}
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
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) {
if strings.HasSuffix(strings.ToLower(fileName), ".json") || (len(data) > 0 && data[0] == '{') {
return PlanMindSiftJSONImport(data)
}
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, errors.New("Die Datei ist weder ein MindSift-Backup (.json/.zip) noch ein MindSift-Markdown-Archiv")
}
var backup *zip.File
for _, f := range zr.File {
name := cleanArchivePath(f.Name)
if strings.EqualFold(path.Ext(name), ".md") {
backup = nil
break
}
if strings.EqualFold(path.Ext(name), ".json") && !hiddenPath(name) {
backup = f
}
}
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)")
}
rc, err := backup.Open()
if err != nil {
return nil, errors.New("Backup im Archiv ist nicht lesbar")
}
defer rc.Close()
raw, err := io.ReadAll(io.LimitReader(rc, maxImportUnpackedBytes+1))
if err != nil || len(raw) > maxImportUnpackedBytes {
return nil, errors.New("Backup im Archiv ist nicht lesbar oder zu groß")
}
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
// exporting user belong to someone else and are skipped; discarded thoughts are not restored.
func PlanMindSiftJSONImport(data []byte) (*ImportPlan, error) {
var exp domain.FullExport
if err := json.Unmarshal(data, &exp); err != nil || (exp.Version == "" && exp.ExportedAt == "") {
return nil, errors.New("Die Datei ist kein MindSift-JSON-Backup")
}
plan := &ImportPlan{}
folders := map[string]bool{}
for _, d := range exp.Documents {
if !d.IsOwner && d.Permission != "" && d.Permission != "owner" {
plan.skip(d.Title, "geteilte Notiz eines anderen Benutzers")
continue
}
if d.FolderPath != "" {
folders[d.FolderPath] = true
}
blocks := make([]domain.DocumentBlock, len(d.Blocks))
for i, b := range d.Blocks {
b.ID, b.DocumentID, b.TaskID = domain.NewID("blk"), "", ""
blocks[i] = b
}
plan.Docs = append(plan.Docs, importDoc{folder: d.FolderPath, title: d.Title, icon: d.Icon, blocks: blocks})
}
listIndex := map[string]int{}
for _, tl := range exp.TaskLists {
if !tl.IsOwner {
plan.skip(tl.Title, "geteilte Aufgabenliste eines anderen Benutzers")
continue
}
listIndex[tl.ID] = len(plan.TaskLists)
plan.TaskLists = append(plan.TaskLists, importTaskList{title: tl.Title, icon: tl.Icon})
}
for _, t := range exp.Thoughts {
task := importTask{content: t.Content, priority: t.Priority, due: t.DueDate, notes: t.Notes, done: t.IsCompleted, subtasks: t.Subtasks, recurrence: t.Recurrence}
switch {
case t.Status == domain.StatusInbox:
plan.Inbox = append(plan.Inbox, task)
case t.Status == domain.StatusKept || (t.Status == domain.StatusConverted && t.ConvertedType == "task"):
if i, ok := listIndex[t.TaskListID]; ok {
plan.TaskLists[i].tasks = append(plan.TaskLists[i].tasks, task)
} else {
plan.TaskLists = append(plan.TaskLists, importTaskList{tasks: []importTask{task}})
}
}
}
for _, l := range exp.Lists {
if !l.IsOwner {
plan.skip(l.Title, "geteilte Liste eines anderen Benutzers")
continue
}
il := importList{title: l.Title, icon: l.Icon}
for _, it := range l.Items {
il.items = append(il.items, importItem{content: it.Content, category: it.Category, quantity: it.Quantity, done: it.IsCompleted})
}
plan.Lists = append(plan.Lists, il)
}
plan.Themes = exp.Themes
plan.Folders = sortedKeys(folders)
return plan, nil
}
// --- Applying ---------------------------------------------------------------------------------------
// ApplyImport creates everything of the plan for the user. Existing data is never changed; imported
// items are added next to it. Task lists of the same name get merged into one new list per import.
func (s *Store) ApplyImport(userID string, plan *ImportPlan) (ImportSummary, error) {
for _, f := range plan.Folders {
_, _ = s.CreateFolder(userID, f) // already existing folders are fine
}
for _, d := range plan.Docs {
title := strings.TrimSpace(d.title)
if title == "" {
title = "Unbenannte Notiz"
}
icon := d.icon
if icon == "" {
icon = "📄"
}
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)
}
}
for _, tl := range plan.TaskLists {
listID := "" // tasks without a list
if tl.title != "" {
icon := tl.icon
if icon == "" {
icon = "📋"
}
created, err := s.CreateTaskList(userID, "", "", tl.title, icon, "")
if err != nil {
return plan.Summary(false), fmt.Errorf("Aufgabenliste %q: %w", tl.title, err)
}
listID = created.ID
}
for _, t := range tl.tasks {
if err := s.importTaskInto(userID, listID, t, true); err != nil {
return plan.Summary(false), err
}
}
}
for _, t := range plan.Inbox {
if err := s.importTaskInto(userID, "", t, false); err != nil {
return plan.Summary(false), err
}
}
for _, l := range plan.Lists {
icon := l.icon
if icon == "" {
icon = "🛒"
}
created, err := s.CreateList(userID, l.title, icon, "shopping")
if err != nil {
return plan.Summary(false), fmt.Errorf("Liste %q: %w", l.title, err)
}
for i, it := range l.items {
item, err := s.CreateListItem(created.ID, it.content, it.category, it.quantity, i, userID)
if err != nil {
continue // empty lines and the like
}
if it.done {
_, _ = s.ToggleListItem(item.ID, true, userID, "")
}
}
}
for _, th := range plan.Themes {
if _, err := s.SaveUserTheme(userID, "", th.Name, th.CSS); err != nil {
plan.skip(th.Name, "Theme: "+err.Error())
}
}
return plan.Summary(true), nil
}
func (s *Store) importTaskInto(userID, listID string, t importTask, asTask bool) error {
content := strings.TrimSpace(t.content)
if content == "" {
return nil
}
priority := t.priority
if priority != "low" && priority != "high" && priority != "urgent" {
priority = "normal"
}
th, err := s.CreateThoughtWithDetails(userID, content, priority, "import", t.due, listID, "todo", t.notes)
if err != nil {
return fmt.Errorf("Aufgabe %q: %w", content, err)
}
if !asTask {
return nil
}
if _, err := s.UpdateThoughtTriage(userID, th.ID, "keep", "task", "", t.due, listID); err != nil {
return fmt.Errorf("Aufgabe %q: %w", content, err)
}
if validRecurrence(t.recurrence) && t.recurrence != "" {
// Imported as already handled: completing a restored task must not create a second successor
_, _ = s.db.Exec(`UPDATE thoughts SET recurrence = ?, recurrence_spawned = ? WHERE id = ?`, t.recurrence, boolInt(t.done), th.ID)
}
if t.done || len(t.subtasks) > 0 {
var done *bool
if t.done {
done = &t.done
}
var subs *[]domain.SubTask
if len(t.subtasks) > 0 {
subs = &t.subtasks
}
_, _ = s.UpdateThoughtDetails(userID, th.ID, nil, done, nil, nil, nil, nil, nil, subs, nil)
}
return nil
}
// --- Markdown parsing -------------------------------------------------------------------------------
type frontmatter struct {
title, icon string
tags []string
}
// splitFrontmatter reads the few keys MindSift uses (title, icon, tags/tag) from YAML front matter.
// It is deliberately tolerant: unknown keys and nested structures are ignored.
func splitFrontmatter(text string) (frontmatter, string) {
var fm frontmatter
if !strings.HasPrefix(text, "---\n") {
return fm, text
}
end := strings.Index(text[4:], "\n---")
if end < 0 {
return fm, text
}
head := text[4 : 4+end]
body := strings.TrimLeft(text[4+end+4:], "\n")
key := ""
for _, line := range strings.Split(head, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "- ") && (key == "tags" || key == "tag") {
fm.tags = append(fm.tags, yamlScalar(trimmed[2:]))
continue
}
k, v, ok := strings.Cut(line, ":")
if !ok || strings.HasPrefix(line, " ") {
continue
}
key = strings.TrimSpace(strings.ToLower(k))
v = strings.TrimSpace(v)
switch key {
case "title":
fm.title = yamlScalar(v)
case "icon":
fm.icon = yamlScalar(v)
case "tags", "tag":
v = strings.Trim(v, "[]")
for _, t := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' }) {
if t = yamlScalar(t); t != "" {
fm.tags = append(fm.tags, t)
}
}
}
}
return fm, body
}
func yamlScalar(v string) string {
v = strings.TrimSpace(v)
if strings.HasPrefix(v, `"`) {
var out string
if json.Unmarshal([]byte(v), &out) == nil {
return out
}
}
return strings.Trim(v, `"'`)
}
func missingTags(tags []string, body string) string {
lower := strings.ToLower(body)
var out []string
for _, t := range tags {
t = strings.TrimPrefix(strings.TrimSpace(t), "#")
if t == "" || strings.Contains(lower, "#"+strings.ToLower(t)) {
continue
}
out = append(out, "#"+strings.ReplaceAll(t, " ", "_"))
}
return strings.Join(out, " ")
}
var (
listItemRe = regexp.MustCompile(`^([ \t]*)(?:[-*+]|\d+[.)])(?:[ \t]+(.*))?$`)
checkboxRe = regexp.MustCompile(`^\[([ xX])\][ \t]*(.*)$`)
headingRe = regexp.MustCompile(`^(#{1,6})[ \t]+(.*)$`)
fenceRe = regexp.MustCompile("^([ \\t]*)(```|~~~)(.*)$")
calloutRe = regexp.MustCompile(`^\[![\w-]+\][+-]?[ \t]*(.*)$`)
)
func newBlock(typ, content string, level int) domain.DocumentBlock {
return domain.DocumentBlock{ID: domain.NewID("blk"), Type: typ, Content: content, Level: level}
}
// indentWidth counts a tab as 4 columns.
func indentWidth(s string) int {
w := 0
for _, r := range s {
if r == '\t' {
w += 4
} else {
w++
}
}
return w
}
// MarkdownToBlocks turns Markdown into outliner blocks. Lists keep their nesting, headings start a
// section whose following content is nested below them (the reverse of the export), callouts and
// quotes become callout blocks, fenced code becomes a code block, plain paragraphs become bullets.
func MarkdownToBlocks(md string) []domain.DocumentBlock {
lines := strings.Split(md, "\n")
var blocks []domain.DocumentBlock
base := 0 // level of content in the current heading section
var headings []int // ranks (1–3) of the open headings: H2 below H1 nests one level deeper
var indents []int // indent widths of the open list levels
inList := false // the previous line belonged to a list item (for continuation lines)
// A list right after a paragraph ("Folgende Punkte:") nests under that paragraph
afterParagraph, listBase := false, 0
listLevel := func(width int) int {
if len(indents) == 0 {
listBase = base
if afterParagraph {
listBase = base + 1
}
}
for len(indents) > 0 && indents[len(indents)-1] > width {
indents = indents[:len(indents)-1]
}
if len(indents) == 0 || indents[len(indents)-1] < width {
indents = append(indents, width)
}
return listBase + len(indents) - 1
}
for i := 0; i < len(lines); i++ {
line := lines[i]
trimmed := strings.TrimSpace(line)
if trimmed == "" {
inList = false
continue
}
// Fenced code (also inside lists)
if m := fenceRe.FindStringSubmatch(line); m != nil {
fence, lang, prefix := m[2], strings.TrimSpace(m[3]), m[1]
var code []string
for i++; i < len(lines); i++ {
l := lines[i]
if strings.HasPrefix(strings.TrimSpace(l), fence) {
break
}
code = append(code, strings.TrimPrefix(l, prefix))
}
level := base
if prefix != "" && len(indents) > 0 {
level = listLevel(indentWidth(prefix))
}
b := newBlock("code", strings.Join(code, "\n"), level)
b.Lang = lang
blocks = append(blocks, b)
inList, afterParagraph = false, false
continue
}
if m := headingRe.FindStringSubmatch(line); m != nil && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
typ := map[int]string{1: "h1", 2: "h2"}[len(m[1])]
if typ == "" {
typ = "h3"
}
rank := len(m[1])
if rank > 3 {
rank = 3
}
for len(headings) > 0 && headings[len(headings)-1] >= rank {
headings = headings[:len(headings)-1]
}
blocks = append(blocks, newBlock(typ, strings.TrimSpace(m[2]), len(headings)))
headings = append(headings, rank)
base, indents, inList, afterParagraph = len(headings), nil, false, false
continue
}
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
blocks = append(blocks, newBlock("divider", "", 0))
base, headings, indents, inList, afterParagraph = 0, nil, nil, false, false
continue
}
}
// Quotes and Obsidian callouts (> [!note] …): one callout block
if strings.HasPrefix(trimmed, ">") && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
var parts []string
for ; i < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i]), ">"); i++ {
q := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(lines[i]), ">"))
if m := calloutRe.FindStringSubmatch(q); m != nil {
if q = strings.TrimSpace(m[1]); q == "" {
continue
}
}
parts = append(parts, q)
}
i--
blocks = append(blocks, newBlock("callout", strings.Join(parts, "\n"), base))
indents, inList, afterParagraph = nil, false, false
continue
}
if m := listItemRe.FindStringSubmatch(line); m != nil {
level := listLevel(indentWidth(m[1]))
content := m[2]
typ := "bullet"
if t := strings.TrimLeft(line, " \t"); t != "" && t[0] >= '0' && t[0] <= '9' {
typ = "numbered"
}
checked := false
if c := checkboxRe.FindStringSubmatch(content); c != nil {
typ, checked, content = "todo", c[1] != " ", c[2]
} else if h := headingRe.FindStringSubmatch(content); h != nil {
typ = map[int]string{1: "h1", 2: "h2"}[len(h[1])]
if typ == "" {
typ = "h3"
}
content = strings.TrimSpace(h[2])
} else if strings.TrimSpace(content) == "---" {
typ, content = "divider", ""
} else if strings.HasPrefix(content, "> ") {
typ, content = "callout", strings.TrimPrefix(content, "> ")
}
b := newBlock(typ, content, level)
b.Checked = checked
blocks = append(blocks, b)
inList = true
continue
}
// Indented text right below a list item continues it (soft line break)
if inList && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && len(blocks) > 0 {
last := &blocks[len(blocks)-1]
last.Content += "\n" + strings.TrimPrefix(trimmed, "> ")
continue
}
// Plain paragraph (consecutive lines form one block)
var para []string
for ; i < len(lines); i++ {
l := strings.TrimSpace(lines[i])
if l == "" || headingRe.MatchString(lines[i]) || listItemRe.MatchString(lines[i]) || fenceRe.MatchString(lines[i]) || strings.HasPrefix(l, ">") {
break
}
para = append(para, l)
}
i--
blocks = append(blocks, newBlock("bullet", strings.Join(para, "\n"), base))
indents, inList, afterParagraph = nil, false, true
}
return blocks
}
var (
recurRe = regexp.MustCompile(`(?i)[ \t]*🔁[ \t]*every[ \t]+(day|week|month|year)\b`)
dueRe = regexp.MustCompile(`[ \t]*📅[ \t]*(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2}))?`)
priorityRe = regexp.MustCompile(`[ \t]*(🔺|⏫|🔼|🔽|⏬)`)
)
// parseTaskFile reads a task list in Obsidian Tasks format as written by the export.
func parseTaskFile(body string) []importTask {
var tasks []importTask
for _, line := range strings.Split(body, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
indented := strings.HasPrefix(line, "\t") || strings.HasPrefix(line, " ")
trimmed := strings.TrimSpace(line)
if indented && len(tasks) > 0 {
last := &tasks[len(tasks)-1]
if strings.HasPrefix(trimmed, ">") {
note := strings.TrimSpace(strings.TrimPrefix(trimmed, ">"))
if last.notes != "" {
last.notes += "\n"
}
last.notes += note
} else if m := listItemRe.FindStringSubmatch(trimmed); m != nil {
if c := checkboxRe.FindStringSubmatch(m[2]); c != nil {
last.subtasks = append(last.subtasks, domain.SubTask{ID: domain.NewID("st"), Title: c[2], IsCompleted: c[1] != " ", CreatedAt: domain.NowISO()})
}
}
continue
}
m := listItemRe.FindStringSubmatch(trimmed)
if m == nil {
continue
}
t := importTask{content: m[2], priority: "normal"}
if c := checkboxRe.FindStringSubmatch(m[2]); c != nil {
t.done, t.content = c[1] != " ", c[2]
}
if r := recurRe.FindStringSubmatch(t.content); r != nil {
t.recurrence = map[string]string{"day": "daily", "week": "weekly", "month": "monthly", "year": "yearly"}[strings.ToLower(r[1])]
t.content = recurRe.ReplaceAllString(t.content, "")
}
if d := dueRe.FindStringSubmatch(t.content); d != nil {
t.due = d[1]
if d[2] != "" {
t.due += "T" + d[2]
}
t.content = dueRe.ReplaceAllString(t.content, "")
}
if p := priorityRe.FindStringSubmatch(t.content); p != nil {
t.priority = map[string]string{"🔺": "urgent", "⏫": "high", "🔼": "high", "🔽": "low", "⏬": "low"}[p[1]]
t.content = priorityRe.ReplaceAllString(t.content, "")
}
t.content = strings.TrimSpace(t.content)
tasks = append(tasks, t)
}
return tasks
}
var quantityRe = regexp.MustCompile(`[ \t]*\(([^()]+)\)[ \t]*$`)
// parseListFile reads list entries "- [ ] Milch (2L) #Kühlregal".
func parseListFile(body string) []importItem {
var items []importItem
for _, line := range strings.Split(body, "\n") {
m := listItemRe.FindStringSubmatch(strings.TrimSpace(line))
if m == nil || strings.TrimSpace(m[2]) == "" {
continue
}
it := importItem{content: m[2]}
if c := checkboxRe.FindStringSubmatch(m[2]); c != nil {
it.done, it.content = c[1] != " ", c[2]
}
// A trailing #Category is turned into the category by CreateListItem itself
if q := quantityRe.FindStringSubmatch(stripTrailingTag(it.content)); q != nil {
it.quantity = q[1]
it.content = strings.Replace(it.content, q[0], "", 1)
}
it.content = strings.TrimSpace(it.content)
items = append(items, it)
}
return items
}
func stripTrailingTag(s string) string {
if i := strings.LastIndex(s, " #"); i >= 0 && !strings.Contains(s[i+2:], " ") {
return s[:i]
}
return s
}
// folderPathOf returns the folder of a "/"-separated archive path with every part made safe.
func folderPathOf(p string) string {
dir := path.Dir(p)
if dir == "." || dir == "/" {
return ""
}
parts := make([]string, 0)
for _, part := range strings.Split(dir, "/") {
if seg := segment(part); seg != "" {
parts = append(parts, seg)
}
}
return strings.Join(parts, "/")
}
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
if k != "" {
out = append(out, k)
}
}
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
}