feat(import): restore MindSift data and import Obsidian vaults
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m28s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m28s
- Two separate paths: POST /import/mindsift (JSON backup or the Markdown archive) and POST /import/obsidian (vault ZIP into a folder named after it) - Everything is planned and validated first; ?preview=1 returns what would be created and what is skipped (with reason), nothing is written - MindSift data round-trips: folders, note structure, task lists with subtasks/due dates/priorities/notes, lists with quantity/category/done, triage thoughts from the JSON backup - Markdown to outliner: nested lists, heading sections, callouts, code, paragraphs; front matter tags become hashtags; .obsidian/attachments skipped - Limits against zip bombs and path tricks (100 MB upload, 10 MB per note, 500 MB unpacked, 20k files, no .. escapes) - Settings: two import cards with drag & drop, preview, confirm, result; touch-sized buttons; mobile download/upload verified Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d8a16c6775
commit
1601a6a2d5
9 files changed
+1314
-1
No files matched your search
@@ -110,6 +110,8 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/api/v1/search", s.handleSearch)
|
||||
r.Get("/api/v1/export", s.handleExport)
|
||||
r.Get("/api/v1/export/markdown", s.handleExportMarkdown)
|
||||
r.Post("/api/v1/import/mindsift", s.handleImportMindSift)
|
||||
r.Post("/api/v1/import/obsidian", s.handleImportObsidian)
|
||||
r.Get("/api/v1/themes", s.handleGetThemes)
|
||||
r.Post("/api/v1/themes", s.handleSaveTheme)
|
||||
r.Delete("/api/v1/themes/{id}", s.handleDeleteTheme)
|
||||
@@ -2115,6 +2117,68 @@ func (s *Server) handleDeleteTheme(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
// importUpload reads the multipart field "file" (max. storage.MaxImportUploadBytes).
|
||||
func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxImportUploadBytes+1<<20)
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("Keine Datei erhalten oder Datei zu groß (max. 100 MB)")
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, storage.MaxImportUploadBytes+1))
|
||||
if err != nil || len(data) > storage.MaxImportUploadBytes {
|
||||
return nil, "", fmt.Errorf("Datei zu groß (max. 100 MB)")
|
||||
}
|
||||
return data, header.Filename, nil
|
||||
}
|
||||
|
||||
// runImport answers a preview (?preview=1) with what would be created, otherwise imports.
|
||||
func (s *Server) runImport(w http.ResponseWriter, r *http.Request, plan *storage.ImportPlan, err error) {
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("preview") == "1" {
|
||||
writeJSON(w, http.StatusOK, plan.Summary(false))
|
||||
return
|
||||
}
|
||||
userID := auth.GetUserID(r.Context())
|
||||
sum, err := s.store.ApplyImport(userID, plan)
|
||||
if err != nil {
|
||||
slog.Error("Import abgebrochen", "user_id", userID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "Import abgebrochen: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sum)
|
||||
}
|
||||
|
||||
// handleImportMindSift restores MindSift's own data: the JSON backup or the Markdown archive (ZIP).
|
||||
func (s *Server) handleImportMindSift(w http.ResponseWriter, r *http.Request) {
|
||||
data, name, err := importUpload(w, r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(name), ".json") || (len(data) > 0 && data[0] == '{') {
|
||||
plan, err := storage.PlanMindSiftJSONImport(data)
|
||||
s.runImport(w, r, plan, err)
|
||||
return
|
||||
}
|
||||
plan, err := storage.PlanMindSiftArchiveImport(bytes.NewReader(data), int64(len(data)))
|
||||
s.runImport(w, r, plan, err)
|
||||
}
|
||||
|
||||
// handleImportObsidian imports an Obsidian vault (ZIP) into a folder named after the vault.
|
||||
func (s *Server) handleImportObsidian(w http.ResponseWriter, r *http.Request) {
|
||||
data, name, err := importUpload(w, r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
plan, err := storage.PlanObsidianImport(bytes.NewReader(data), int64(len(data)), name)
|
||||
s.runImport(w, r, plan, err)
|
||||
}
|
||||
|
||||
// handleExportMarkdown streams an Obsidian-compatible ZIP of the user's workspace (no temp files).
|
||||
func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
|
||||
Reference in new issue
Block a user