Add viewer search and dual pane
This commit is contained in:
parent
9f2973e0e8
commit
c5d761db98
3 changed files with 181 additions and 16 deletions
|
|
@ -33,7 +33,7 @@ func homeHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// renderShell ist das Grundgeruest im Outlook-2013-Look:
|
// renderShell ist das Grundgeruest im Outlook-2013-Look:
|
||||||
// [ Ribbon: Umzug | Postfaecher | Betrachten | Weiterleiten | Einstellungen ]
|
// [ Ribbon: Umzug | Postfaecher | Betrachten | Weiterleiten | Einstellungen ]
|
||||||
// [ Ordnerbaum (links) | Nachrichtenliste (mitte) | Lesebereich (rechts) ]
|
// [ Ordnerbaum | Nachrichtenliste | Original | Zielkopie ]
|
||||||
func renderShell(w http.ResponseWriter, active, readingPane string) {
|
func renderShell(w http.ResponseWriter, active, readingPane string) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||||
|
|
@ -51,7 +51,7 @@ func renderShell(w http.ResponseWriter, active, readingPane string) {
|
||||||
<a class="tab" href="/accounts">Einstellungen</a>
|
<a class="tab" href="/accounts">Einstellungen</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<div class="three-pane">
|
<div class="four-pane">
|
||||||
<aside class="pane pane-tree" id="tree"
|
<aside class="pane pane-tree" id="tree"
|
||||||
hx-get="/view" hx-trigger="load" hx-target="#tree" hx-swap="innerHTML">
|
hx-get="/view" hx-trigger="load" hx-target="#tree" hx-swap="innerHTML">
|
||||||
<div class="pane-head">Postfächer</div>
|
<div class="pane-head">Postfächer</div>
|
||||||
|
|
@ -60,9 +60,13 @@ func renderShell(w http.ResponseWriter, active, readingPane string) {
|
||||||
<div class="pane-head">Nachrichten</div>
|
<div class="pane-head">Nachrichten</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="pane pane-read" id="read">
|
<section class="pane pane-read" id="read">
|
||||||
<div class="pane-head">Lesebereich</div>
|
<div class="pane-head">Original</div>
|
||||||
<div class="read-body">%s</div>
|
<div class="read-body">%s</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="pane pane-read pane-target" id="read-target">
|
||||||
|
<div class="pane-head">Zielkopie</div>
|
||||||
|
<div class="read-body"><div class="ef-empty">Nachricht waehlen.</div></div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<footer class="statusbar"><span id="mig-status"
|
<footer class="statusbar"><span id="mig-status"
|
||||||
hx-get="/migrate/status" hx-trigger="every 3s" hx-swap="innerHTML">bereit</span></footer>
|
hx-get="/migrate/status" hx-trigger="every 3s" hx-swap="innerHTML">bereit</span></footer>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -19,8 +20,9 @@ import (
|
||||||
func viewerHandler(w http.ResponseWriter, r *http.Request) {
|
func viewerHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
account := r.URL.Query().Get("account")
|
account := r.URL.Query().Get("account")
|
||||||
folder := r.URL.Query().Get("folder")
|
folder := r.URL.Query().Get("folder")
|
||||||
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
if account != "" && folder != "" {
|
if account != "" && folder != "" {
|
||||||
renderMessageList(w, account, folder)
|
renderMessageList(w, account, folder, q)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.Header.Get("HX-Request") == "true" {
|
if r.Header.Get("HX-Request") == "true" {
|
||||||
|
|
@ -50,10 +52,10 @@ func messageHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
renderReadPane(w, "(unlesbar)", "", "", string(raw))
|
renderReadPanePair(w, account, folder, index, raw, "(unlesbar)", "", "", string(raw))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
renderReadPane(w,
|
renderReadPanePair(w, account, folder, index, raw,
|
||||||
decodeHeader(msg.Header.Get("Subject")),
|
decodeHeader(msg.Header.Get("Subject")),
|
||||||
decodeHeader(msg.Header.Get("From")),
|
decodeHeader(msg.Header.Get("From")),
|
||||||
decodeHeader(msg.Header.Get("Date")),
|
decodeHeader(msg.Header.Get("Date")),
|
||||||
|
|
@ -98,7 +100,7 @@ func renderViewerTree(w http.ResponseWriter) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderMessageList(w http.ResponseWriter, account, folder string) {
|
func renderMessageList(w http.ResponseWriter, account, folder, q string) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
path, err := mboxPath(account, folder)
|
path, err := mboxPath(account, folder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -110,8 +112,18 @@ func renderMessageList(w http.ResponseWriter, account, folder string) {
|
||||||
fmt.Fprintf(w, `<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
|
fmt.Fprintf(w, `<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, `<div class="pane-head">%s / %s (%d)</div>`, html.EscapeString(account), html.EscapeString(folder), len(entries))
|
filtered := entries
|
||||||
for _, entry := range entries {
|
if q != "" {
|
||||||
|
filtered = filterEntries(path, entries, q)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `<div class="pane-head">%s / %s (%d)</div>`, html.EscapeString(account), html.EscapeString(folder), len(filtered))
|
||||||
|
fmt.Fprintf(w, `<form class="searchbar" hx-get="/view" hx-target="#list" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="account" value="%s">
|
||||||
|
<input type="hidden" name="folder" value="%s">
|
||||||
|
<input class="input search-input" type="search" name="q" value="%s" placeholder="Suchen">
|
||||||
|
<button class="btn secondary search-btn" type="submit">Suchen</button>
|
||||||
|
</form>`, html.EscapeString(account), html.EscapeString(folder), html.EscapeString(q))
|
||||||
|
for _, entry := range filtered {
|
||||||
subject := entry.Subject
|
subject := entry.Subject
|
||||||
if subject == "" {
|
if subject == "" {
|
||||||
subject = "(ohne Betreff)"
|
subject = "(ohne Betreff)"
|
||||||
|
|
@ -126,16 +138,49 @@ func renderMessageList(w http.ResponseWriter, account, folder string) {
|
||||||
}
|
}
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
fmt.Fprint(w, `<div class="ef-empty">Keine Nachrichten in dieser mbox.</div>`)
|
fmt.Fprint(w, `<div class="ef-empty">Keine Nachrichten in dieser mbox.</div>`)
|
||||||
|
} else if len(filtered) == 0 {
|
||||||
|
fmt.Fprint(w, `<div class="ef-empty">Keine Treffer.</div>`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderReadPane(w http.ResponseWriter, subject, from, date, body string) {
|
func renderReadPanePair(w http.ResponseWriter, account, folder string, index int, raw []byte, subject, from, date, body string) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
renderReadPane(w, "Original", subject, from, date, body)
|
||||||
|
targetHTML := renderTargetPane(account, folder, index, raw)
|
||||||
|
fmt.Fprintf(w, `<div id="read-target" hx-swap-oob="innerHTML">%s</div>`, targetHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderReadPane(w io.Writer, title, subject, from, date, body string) {
|
||||||
if subject == "" {
|
if subject == "" {
|
||||||
subject = "(ohne Betreff)"
|
subject = "(ohne Betreff)"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, `<div class="pane-head">Lesebereich</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>`,
|
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(subject), html.EscapeString(from), html.EscapeString(date), html.EscapeString(body))
|
html.EscapeString(title), html.EscapeString(subject), html.EscapeString(from), html.EscapeString(date), html.EscapeString(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTargetPane(account, folder string, index int, raw []byte) string {
|
||||||
|
match, ok := findForwardedCopy(account, folder, index, raw)
|
||||||
|
if !ok {
|
||||||
|
id := messageIDHeader(raw)
|
||||||
|
if id == "" {
|
||||||
|
id = "(ohne Message-ID)"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`<div class="pane-head">Zielkopie</div><div class="ef-empty">Keine lokale Zielkopie gefunden.<br><br>Message-ID: %s</div>`, html.EscapeString(id))
|
||||||
|
}
|
||||||
|
msg, err := mail.ReadMessage(bytes.NewReader(match.Raw))
|
||||||
|
if err != nil {
|
||||||
|
var b strings.Builder
|
||||||
|
renderReadPane(&b, "Zielkopie", "(unlesbar)", match.Location(), "", string(match.Raw))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
renderReadPane(&b, "Zielkopie: "+match.Location(),
|
||||||
|
decodeHeader(msg.Header.Get("Subject")),
|
||||||
|
decodeHeader(msg.Header.Get("From")),
|
||||||
|
decodeHeader(msg.Header.Get("Date")),
|
||||||
|
messageBody(match.Raw),
|
||||||
|
)
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func mboxPath(account, folder string) (string, error) {
|
func mboxPath(account, folder string) (string, error) {
|
||||||
|
|
@ -161,3 +206,80 @@ func urlEsc(s string) string {
|
||||||
r := strings.NewReplacer("%", "%25", " ", "%20", "&", "%26", "?", "%3F", "#", "%23", "+", "%2B", "/", "%2F")
|
r := strings.NewReplacer("%", "%25", " ", "%20", "&", "%26", "?", "%3F", "#", "%23", "+", "%2B", "/", "%2F")
|
||||||
return r.Replace(s)
|
return r.Replace(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filterEntries(path string, entries []MboxEntry, q string) []MboxEntry {
|
||||||
|
needle := strings.ToLower(strings.TrimSpace(q))
|
||||||
|
if needle == "" {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
var out []MboxEntry
|
||||||
|
for _, entry := range entries {
|
||||||
|
hay := strings.ToLower(entry.From + " " + entry.Subject + " " + entry.Date)
|
||||||
|
if strings.Contains(hay, needle) {
|
||||||
|
out = append(out, entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, err := ReadMboxMessage(path, entry.Index)
|
||||||
|
if err == nil && strings.Contains(strings.ToLower(messageBody(raw)), needle) {
|
||||||
|
out = append(out, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type forwardedMatch struct {
|
||||||
|
Account string
|
||||||
|
Folder string
|
||||||
|
Index int
|
||||||
|
Raw []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m forwardedMatch) Location() string {
|
||||||
|
return fmt.Sprintf("%s / %s #%d", m.Account, m.Folder, m.Index)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findForwardedCopy(account, folder string, index int, raw []byte) (forwardedMatch, bool) {
|
||||||
|
id := messageIDHeader(raw)
|
||||||
|
if id == "" {
|
||||||
|
return forwardedMatch{}, false
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(Cfg.MboxRoot)
|
||||||
|
if err != nil {
|
||||||
|
return forwardedMatch{}, false
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() || entry.Name() == account {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, entry.Name(), "*.mbox"))
|
||||||
|
for _, path := range mboxes {
|
||||||
|
msgs, err := ReadMboxList(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, msg := range msgs {
|
||||||
|
candidate, err := ReadMboxMessage(path, msg.Index)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if messageIDHeader(candidate) == id {
|
||||||
|
return forwardedMatch{
|
||||||
|
Account: entry.Name(),
|
||||||
|
Folder: strings.TrimSuffix(filepath.Base(path), ".mbox"),
|
||||||
|
Index: msg.Index,
|
||||||
|
Raw: candidate,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return forwardedMatch{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageIDHeader(raw []byte) string {
|
||||||
|
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(msg.Header.Get("Message-ID"))
|
||||||
|
}
|
||||||
|
|
|
||||||
47
frontend-js/dist/style.css
vendored
47
frontend-js/dist/style.css
vendored
|
|
@ -48,13 +48,15 @@ body.ol2013 {
|
||||||
.tab:hover { background: var(--ol-blue-dark); color: #fff; }
|
.tab:hover { background: var(--ol-blue-dark); color: #fff; }
|
||||||
.tab.active { background: #fff; color: var(--ol-blue); border-bottom-color: #fff; }
|
.tab.active { background: #fff; color: var(--ol-blue); border-bottom-color: #fff; }
|
||||||
|
|
||||||
/* Dreispalter */
|
/* Vier-Spalten-Viewer */
|
||||||
.three-pane {
|
.three-pane,
|
||||||
|
.four-pane {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 220px 340px 1fr;
|
|
||||||
min-height: 0; /* erlaubt Scrollen in den Panes */
|
min-height: 0; /* erlaubt Scrollen in den Panes */
|
||||||
}
|
}
|
||||||
|
.three-pane { grid-template-columns: 220px 340px 1fr; }
|
||||||
|
.four-pane { grid-template-columns: 220px 340px minmax(280px, 1fr) minmax(280px, 1fr); }
|
||||||
.pane {
|
.pane {
|
||||||
border-right: 1px solid var(--ol-border);
|
border-right: 1px solid var(--ol-border);
|
||||||
background: var(--ol-pane);
|
background: var(--ol-pane);
|
||||||
|
|
@ -62,7 +64,7 @@ body.ol2013 {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.pane-tree { background: var(--ol-tree); }
|
.pane-tree { background: var(--ol-tree); }
|
||||||
.pane-read { border-right: 0; }
|
.pane-target { border-right: 0; }
|
||||||
.pane-head {
|
.pane-head {
|
||||||
position: sticky; top: 0;
|
position: sticky; top: 0;
|
||||||
background: #eef3f8;
|
background: #eef3f8;
|
||||||
|
|
@ -72,6 +74,18 @@ body.ol2013 {
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.searchbar {
|
||||||
|
position: sticky; top: 30px; z-index: 2;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid var(--ol-border);
|
||||||
|
}
|
||||||
|
.search-input { min-width: 0; }
|
||||||
|
.search-btn { padding-left: 10px; padding-right: 10px; }
|
||||||
|
|
||||||
/* Ordnerbaum */
|
/* Ordnerbaum */
|
||||||
.tree-node { padding: 4px 12px 4px 24px; cursor: pointer; white-space: nowrap; }
|
.tree-node { padding: 4px 12px 4px 24px; cursor: pointer; white-space: nowrap; }
|
||||||
a.tree-node { display: block; color: inherit; text-decoration: none; }
|
a.tree-node { display: block; color: inherit; text-decoration: none; }
|
||||||
|
|
@ -132,3 +146,28 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
|
||||||
}
|
}
|
||||||
.ok { color: #107c10; }
|
.ok { color: #107c10; }
|
||||||
.bad { color: #d13438; }
|
.bad { color: #d13438; }
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.four-pane {
|
||||||
|
grid-template-columns: 200px 320px minmax(300px, 1fr);
|
||||||
|
}
|
||||||
|
.pane-target {
|
||||||
|
grid-column: 3;
|
||||||
|
border-top: 1px solid var(--ol-border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.four-pane,
|
||||||
|
.three-pane {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.pane {
|
||||||
|
min-height: 220px;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--ol-border);
|
||||||
|
}
|
||||||
|
.pane-target { grid-column: auto; }
|
||||||
|
.searchbar { top: 30px; }
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue