Add manual target drag and drop

This commit is contained in:
DonVoo 2026-07-06 11:02:29 +02:00
parent ffa28f6182
commit a4eee37ab9
4 changed files with 229 additions and 35 deletions

View file

@ -21,6 +21,7 @@ func RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/view", viewerHandler)
mux.HandleFunc("/view/message", messageHandler)
mux.HandleFunc("/view/target", targetMessageHandler)
mux.HandleFunc("/view/manual-copy", manualCopyHandler)
mux.HandleFunc("/view/forward", forwardHandler)
}
@ -34,7 +35,7 @@ func homeHandler(w http.ResponseWriter, r *http.Request) {
// renderShell ist das Grundgeruest im Outlook-2013-Look:
// [ Ribbon: Postfaecher | Betrachten | Betrachen | Einstellungen | Mail-Graveyard ]
// [ Ordnerbaum | Nachrichtenliste | Original | Zielkopie ]
// [ Archiv-mbox | Nachrichtenliste | Quelle/Target ]
func renderShell(w http.ResponseWriter, active, readingPane string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
@ -53,7 +54,7 @@ func renderShell(w http.ResponseWriter, active, readingPane string) {
</nav>
<div class="ribbon-title">Mail-Graveyard</div>
</header>
<div class="four-pane">
<div class="three-pane">
<aside class="pane pane-tree" id="tree"
hx-get="/view" hx-trigger="load" hx-target="#tree" hx-swap="innerHTML">
<div class="pane-head">Postf&auml;cher</div>
@ -64,17 +65,13 @@ func renderShell(w http.ResponseWriter, active, readingPane string) {
</section>
<div class="pane-resizer" data-resize-index="1"></div>
<section class="pane pane-read" id="read">
<div class="pane-head">Original</div>
<div class="pane-head">Quelle</div>
<div class="read-body">%s</div>
</section>
<div class="pane-resizer" data-resize-index="2"></div>
<section class="pane pane-read pane-target" id="read-target">
%s
</section>
</div>
<footer class="statusbar"><span id="mig-status"
hx-get="/migrate/status" hx-trigger="every 3s" hx-swap="innerHTML">bereit</span></footer>
</body></html>`, active, readingPane, renderInitialTargetPane())
</body></html>`, active, readingPane)
}
// --- Stubs fuer die noch nicht gebauten Handler (TODO Codex) ---

View file

@ -11,6 +11,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
)
// Der Viewer betrachtet die LOKALEN mbox-Dateien im Browser (Outlook-2013-
@ -53,10 +54,10 @@ func messageHandler(w http.ResponseWriter, r *http.Request) {
}
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
renderReadPanePair(w, account, folder, targetAccount, index, raw, "(unlesbar)", "", "", string(raw))
renderReadPaneWithSource(w, account, folder, targetAccount, index, "(unlesbar)", "", "", string(raw))
return
}
renderReadPanePair(w, account, folder, targetAccount, index, raw,
renderReadPaneWithSource(w, account, folder, targetAccount, index,
decodeHeader(msg.Header.Get("Subject")),
decodeHeader(msg.Header.Get("From")),
decodeHeader(msg.Header.Get("Date")),
@ -84,7 +85,7 @@ func targetMessageHandler(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, renderTargetPane(account, folder, targetAccount, index, raw))
fmt.Fprint(w, renderTargetPane(account, folder, targetAccount, index, raw, ""))
}
func forwardHandler(w http.ResponseWriter, r *http.Request) {
@ -157,7 +158,8 @@ func renderMessageList(w http.ResponseWriter, account, folder, q string) {
if from == "" {
from = "(ohne Absender)"
}
fmt.Fprintf(w, `<a class="msg-row" href="#" hx-get="/view/message?source=%s&amp;folder=%s&amp;index=%d" hx-include="#target-account-select" hx-target="#read" hx-swap="innerHTML"><span class="msg-from">%s</span><span class="msg-subject">%s</span><span class="msg-date">%s</span></a>`,
fmt.Fprintf(w, `<a class="msg-row" href="#" draggable="true" data-source="%s" data-folder="%s" data-index="%d" hx-get="/view/message?source=%s&amp;folder=%s&amp;index=%d" hx-include="#target-account-select" hx-target="#read" hx-swap="innerHTML"><span class="msg-from">%s</span><span class="msg-subject">%s</span><span class="msg-date">%s</span></a>`,
html.EscapeString(sourceDisplayLabel(account)), html.EscapeString(folder), entry.Index,
urlEsc(sourceDisplayLabel(account)), urlEsc(folder), entry.Index,
html.EscapeString(from), html.EscapeString(subject), html.EscapeString(entry.Date))
}
@ -168,24 +170,60 @@ func renderMessageList(w http.ResponseWriter, account, folder, q string) {
}
}
func renderReadPanePair(w http.ResponseWriter, account, folder, targetAccount string, index int, raw []byte, subject, from, date, body string) {
func manualCopyHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
account := resolveViewerAccount(r.FormValue("account"), r.FormValue("source"))
folder := r.FormValue("folder")
targetAccount := r.FormValue("target_account")
index, err := strconv.Atoi(r.FormValue("index"))
if err != nil {
http.Error(w, "bad index", http.StatusBadRequest)
return
}
path, err := mboxPath(account, folder)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
raw, err := ReadMboxMessage(path, index)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
note, err := appendManualTargetCopy(account, folder, targetAccount, raw)
if err != nil {
note = "Fehler beim manuellen Umzug: " + err.Error()
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
renderReadPane(w, "Original", subject, from, date, body)
targetHTML := renderTargetPane(account, folder, targetAccount, index, raw)
fmt.Fprintf(w, `<div id="read-target" hx-swap-oob="innerHTML">%s</div>`, targetHTML)
fmt.Fprint(w, renderTargetPane(account, folder, targetAccount, index, raw, note))
}
func renderReadPane(w io.Writer, title, subject, from, date, body string) {
func renderReadPaneWithSource(w http.ResponseWriter, account, folder, targetAccount string, index int, subject, from, date, body string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
renderReadPane(w, "Quelle", account, folder, targetAccount, index, subject, from, date, body)
}
func renderReadPane(w io.Writer, title, account, folder, targetAccount string, index int, subject, from, date, body string) {
if subject == "" {
subject = "(ohne Betreff)"
}
fmt.Fprintf(w, `<div class="pane-head">%s</div><div class="read-body"><div class="read-head"><h1 class="read-subject">%s</h1><div class="read-meta">Von: %s<br>Datum: %s</div></div><pre class="read-pre">%s</pre></div>`,
html.EscapeString(title), html.EscapeString(subject), html.EscapeString(from), html.EscapeString(date), html.EscapeString(body))
fmt.Fprintf(w, `<div class="pane-head read-toolbar"><span>%s</span><button class="btn secondary compact" type="button" hx-get="/view/target?source=%s&amp;folder=%s&amp;index=%d&amp;target_account=%s" hx-target="#read" hx-swap="innerHTML">Target</button></div><div class="read-body"><div class="read-head"><h1 class="read-subject">%s</h1><div class="read-meta">Von: %s<br>Datum: %s</div></div><pre class="read-pre">%s</pre></div>`,
html.EscapeString(title), urlEsc(sourceDisplayLabel(account)), urlEsc(folder), index, urlEsc(targetAccount), html.EscapeString(subject), html.EscapeString(from), html.EscapeString(date), html.EscapeString(body))
}
func renderTargetPane(account, folder, targetAccount string, index int, raw []byte) string {
func renderTargetPane(account, folder, targetAccount string, index int, raw []byte, note string) string {
var b strings.Builder
b.WriteString(renderTargetAccountSelect(account, folder, targetAccount, index))
if note != "" {
class := "ok"
if strings.HasPrefix(note, "Fehler") {
class = "bad"
}
fmt.Fprintf(&b, `<div class="audit-note %s">%s</div>`, class, html.EscapeString(note))
}
if match, ok, err := findTargetIMAPCopy(account, targetAccount, raw); ok {
renderTargetMatch(&b, match)
return b.String()
@ -220,10 +258,7 @@ func renderTargetMatch(b *strings.Builder, match forwardedMatch) {
}
func renderInitialTargetPane() string {
var b strings.Builder
b.WriteString(renderTargetAccountSelect("", "", "", -1))
b.WriteString(`<div class="read-body"><div class="ef-empty">Nachricht waehlen.</div></div>`)
return b.String()
return `<div class="pane-head">Quelle</div><div class="read-body"><div class="ef-empty">Nachricht waehlen.</div></div>`
}
func renderReadContent(w io.Writer, subject, from, date, body string) {
@ -399,9 +434,9 @@ func messageIDHeader(raw []byte) string {
func renderTargetAccountSelect(account, folder, selected string, index int) string {
options := targetAccountOptions()
var b strings.Builder
fmt.Fprintf(&b, `<div class="pane-head target-head"><span>Zielkopie</span><form class="target-select-form"`)
fmt.Fprintf(&b, `<div class="pane-head target-head"><span>Target</span><form class="target-select-form"`)
if account != "" && folder != "" && index >= 0 {
fmt.Fprintf(&b, ` hx-get="/view/target" hx-target="#read-target" hx-swap="innerHTML" hx-trigger="change from:#target-account-select"`)
fmt.Fprintf(&b, ` hx-get="/view/target" hx-target="#read" hx-swap="innerHTML" hx-trigger="change from:#target-account-select"`)
}
fmt.Fprintf(&b, `>
<input type="hidden" name="source" value="%s">
@ -416,10 +451,73 @@ func renderTargetAccountSelect(account, folder, selected string, index int) stri
}
fmt.Fprintf(&b, `<option value="%s"%s>%s</option>`, html.EscapeString(opt.Value), attr, html.EscapeString(opt.Label))
}
b.WriteString(`</select></form></div>`)
fmt.Fprintf(&b, `</select></form><button class="btn secondary compact" type="button" hx-get="/view/message?source=%s&amp;folder=%s&amp;index=%d&amp;target_account=%s" hx-target="#read" hx-swap="innerHTML">Quelle</button></div>`,
urlEsc(sourceDisplayLabel(account)), urlEsc(folder), index, urlEsc(selected))
if account != "" && folder != "" && index >= 0 {
fmt.Fprintf(&b, `<div class="drop-zone" data-drop-target="manual-copy" data-source="%s" data-folder="%s" data-index="%d">Mail hier ablegen fuer manuellen Umzug</div>`,
html.EscapeString(sourceDisplayLabel(account)), html.EscapeString(folder), index)
}
return b.String()
}
func appendManualTargetCopy(account, folder, targetAccount string, raw []byte) (string, error) {
id := messageIDHeader(raw)
if id == "" {
return "", fmt.Errorf("mail ohne Message-ID kann nicht sauber dedupliziert werden")
}
if _, ok, err := findTargetIMAPCopy(account, targetAccount, raw); ok {
return "Schon vorhanden: Zielkopie wurde per Message-ID gefunden.", nil
} else if err != nil {
return "", err
}
a, ok := accountByMboxDir(account)
if !ok {
return "", fmt.Errorf("kein Konto zur mbox %s gefunden", account)
}
if strings.TrimSpace(targetAccount) != "" && !strings.EqualFold(strings.TrimSpace(a.DstUser), strings.TrimSpace(targetAccount)) {
candidates := accountsForTargetUser(targetAccount)
if len(candidates) == 0 {
return "", fmt.Errorf("kein Zielkonto %s gefunden", targetAccount)
}
a = candidates[0]
}
dst, err := OpenIMAPTarget(a)
if err != nil {
return "", err
}
defer dst.Close()
targets, err := dst.Folders()
if err != nil {
return "", err
}
dstFolder := MapSourceToTarget(folder, nil, "/", dst.Delim(), targets, "")
if dstFolder == "" {
dstFolder = folder
}
if err := dst.EnsureFolder(dstFolder); err != nil {
return "", err
}
msg := RawMessage{MessageID: id, Body: raw, InternalDate: messageDate(raw)}
if err := dst.Append(dstFolder, msg); err != nil {
return "", err
}
if a.ID != 0 {
_ = MarkCopied(a.ID, folder, id)
}
return "Manuell kopiert nach " + strings.TrimSpace(a.DstUser) + " / " + dstFolder + ".", nil
}
func messageDate(raw []byte) time.Time {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return time.Time{}
}
if t, err := mail.ParseDate(msg.Header.Get("Date")); err == nil {
return t
}
return time.Time{}
}
type viewerAccount struct {
Name string
SourceLabel string