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

View file

@ -1,6 +1,6 @@
(function () {
const key = "mail-graveyard-pane-widths";
const minWidths = [160, 220, 260, 260];
const key = "mail-graveyard-pane-widths-v2";
const minWidths = [160, 220, 300];
const handleWidth = 6;
function panes(container) {
@ -13,7 +13,7 @@
function clampWidths(widths, total) {
const next = widths.map((w, i) => Math.max(minWidths[i], Math.round(w || minWidths[i])));
const max = Math.max(total - handleWidth * 3, minWidths.reduce((a, b) => a + b, 0));
const max = Math.max(total - handleWidth * (next.length - 1), minWidths.reduce((a, b) => a + b, 0));
const sum = next.reduce((a, b) => a + b, 0);
if (sum > max) {
let extra = sum - max;
@ -31,14 +31,14 @@
const total = container.getBoundingClientRect().width;
const w = clampWidths(widths, total);
container.style.gridTemplateColumns =
`${w[0]}px ${handleWidth}px ${w[1]}px ${handleWidth}px ${w[2]}px ${handleWidth}px ${w[3]}px`;
`${w[0]}px ${handleWidth}px ${w[1]}px ${handleWidth}px minmax(${w[2]}px, 1fr)`;
localStorage.setItem(key, JSON.stringify(w));
}
function restore(container) {
try {
const saved = JSON.parse(localStorage.getItem(key) || "null");
if (Array.isArray(saved) && saved.length === 4) {
if (Array.isArray(saved) && saved.length === 3) {
applyWidths(container, saved);
}
} catch (_) {
@ -90,4 +90,79 @@
}
window.addEventListener("DOMContentLoaded", () => bind(document.querySelector(".four-pane")));
window.addEventListener("DOMContentLoaded", () => bind(document.querySelector(".three-pane")));
})();
(function () {
function rowPayload(row) {
return {
source: row.dataset.source || "",
folder: row.dataset.folder || "",
index: row.dataset.index || ""
};
}
document.addEventListener("dragstart", (event) => {
const row = event.target.closest(".msg-row[draggable='true']");
if (!row || !event.dataTransfer) return;
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData("application/json", JSON.stringify(rowPayload(row)));
event.dataTransfer.setData("text/plain", `${row.dataset.source || ""}/${row.dataset.folder || ""}#${row.dataset.index || ""}`);
row.classList.add("dragging");
});
document.addEventListener("dragend", (event) => {
const row = event.target.closest(".msg-row");
if (row) row.classList.remove("dragging");
});
document.addEventListener("dragover", (event) => {
const zone = event.target.closest("[data-drop-target='manual-copy']");
if (!zone) return;
event.preventDefault();
zone.classList.add("drag-over");
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
});
document.addEventListener("dragleave", (event) => {
const zone = event.target.closest("[data-drop-target='manual-copy']");
if (zone) zone.classList.remove("drag-over");
});
document.addEventListener("drop", async (event) => {
const zone = event.target.closest("[data-drop-target='manual-copy']");
if (!zone) return;
event.preventDefault();
zone.classList.remove("drag-over");
let payload = {};
try {
payload = JSON.parse(event.dataTransfer.getData("application/json") || "{}");
} catch (_) {
payload = {};
}
if (!payload.source) {
payload = {
source: zone.dataset.source || "",
folder: zone.dataset.folder || "",
index: zone.dataset.index || ""
};
}
const select = document.querySelector("#target-account-select");
const form = new URLSearchParams();
form.set("source", payload.source);
form.set("folder", payload.folder);
form.set("index", payload.index);
form.set("target_account", select ? select.value : "");
zone.textContent = "Kopiere...";
const response = await fetch("/view/manual-copy", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" },
body: form.toString()
});
const html = await response.text();
document.querySelector("#read").innerHTML = html;
});
})();

View file

@ -48,14 +48,14 @@ body.ol2013 {
.tab:hover { background: var(--ol-blue-dark); color: #fff; }
.tab.active { background: #fff; color: var(--ol-blue); border-bottom-color: #fff; }
/* Vier-Spalten-Viewer */
/* Viewer */
.three-pane,
.four-pane {
flex: 1 1 auto;
display: grid;
min-height: 0; /* erlaubt Scrollen in den Panes */
}
.three-pane { grid-template-columns: 220px 340px 1fr; }
.three-pane { grid-template-columns: 220px 6px 340px 6px minmax(300px, 1fr); }
.four-pane { grid-template-columns: 220px 6px 340px 6px minmax(280px, 1fr) 6px minmax(280px, 1fr); }
.pane {
border-right: 1px solid var(--ol-border);
@ -84,7 +84,7 @@ body.resizing { cursor: col-resize; user-select: none; }
}
.target-head {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
}
@ -124,6 +124,7 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
display: flex; flex-direction: column; gap: 2px;
}
.msg-row:hover { background: #eef6fd; }
.msg-row.dragging { opacity: .55; }
.msg-row.active { background: var(--ol-sel); border-left: 3px solid var(--ol-blue); padding-left: 9px; }
.msg-from { font-weight: 600; }
.msg-subject { color: var(--ol-text); }
@ -133,6 +134,12 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
/* Lesebereich */
.read-body { padding: 16px 20px; }
.read-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.read-head { border-bottom: 1px solid var(--ol-border); padding-bottom: 10px; margin-bottom: 12px; }
.read-subject { font-size: 17px; font-weight: 600; margin: 0 0 6px; }
.read-meta { color: var(--ol-muted); font-size: 12px; }
@ -145,6 +152,19 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
}
.ef-empty { color: var(--ol-muted); padding: 24px; }
.audit-note { padding: 10px 16px; border-bottom: 1px solid var(--ol-border); font-size: 12px; }
.drop-zone {
margin: 10px 16px 0;
padding: 12px;
border: 1px dashed var(--ol-sel-bd);
background: #f6fbff;
color: var(--ol-muted);
text-align: center;
}
.drop-zone.drag-over {
background: var(--ol-sel);
color: var(--ol-text);
border-color: var(--ol-blue);
}
/* Buttons / Formulare (flach, 2013er) */
.btn {
@ -154,6 +174,7 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
.btn:hover { background: var(--ol-blue-dark); }
.btn.secondary { background: #e1e1e1; color: var(--ol-text); border: 1px solid var(--ol-border); }
.btn.secondary:hover { background: #d5d5d5; }
.btn.compact { padding: 3px 9px; }
.input, select, textarea {
border: 1px solid #abadb3; padding: 5px 7px; font: inherit; width: 100%;
background: #fff;
@ -172,6 +193,9 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
.bad { color: #d13438; }
@media (max-width: 1180px) {
.three-pane {
grid-template-columns: 200px 6px 320px 6px minmax(300px, 1fr);
}
.four-pane {
grid-template-columns: 200px 6px 320px 6px minmax(280px, 1fr) 6px minmax(280px, 1fr);
}