Zwei Widgets: Zettel mit kleinem Markdown, Bild je Nutzer

Zettel: ein Textfeld, mehr nicht — InkNote gibt es schon. Gelesen wird
gesetzt (Ueberschriften, Haken, Aufzaehlungen, fett/kursiv/code, Strich),
geschrieben wird roh. Haken lassen sich ohne Bearbeiten setzen und
aendern die Quelle. Gespeichert wird 800 ms nach dem letzten Anschlag,
sofort beim Verlassen und beim Schliessen des Tabs (keepalive statt
sendBeacon: der kann nur POST und traegt keine Kopfzeilen). Grenze 4000
Zeichen, vom Server durchgesetzt.

Bild: genau eines je Nutzer, hochgeladen oder verlinkt. Die erste Datei,
die HoneyOS ablegt — in bilder/ neben honeyos.json, benannt nach einem
Fingerabdruck der Nutzerkennung. Ein neues Bild ersetzt das alte samt
Datei, die Adresse traegt einen Versionsanteil gegen den Zwischen-
speicher. SVG kommt nicht herein (Text, kann Skripte tragen), und der
Typ wird an den Bytes erkannt, nicht an der Angabe des Browsers.

Der Rahmen kann jetzt Widgets mit eigenem DOM: 'stabil' haelt den Inhalt,
'montiert' verdrahtet ihn. Ohne das haette das Anklopfen bei den Apps
alle zwei Minuten den Text unter den Fingern ausgetauscht.

Nebenbei: Der Aufrufhelfer setzte bei jedem Schreibzugriff
Content-Type: application/json — auch fuer Bildbytes.
This commit is contained in:
marc committed 2026-08-31 00:44:20 +02:00
1 parent b7cc06b7c1
commit c0abfbb491
9 files changed
+857 -20

No files matched your search

+126
View File
@@ -3,14 +3,18 @@
package store
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// Der ganze Zustand von HoneyOS passt in eine JSON-Datei: eine Handvoll Apps
@@ -35,6 +39,17 @@ type User struct {
Name string `json:"name,omitempty"`
Picture string `json:"picture,omitempty"`
Settings map[string]string `json:"settings,omitempty"`
// Der Zettel. Nicht in den Einstellungen, obwohl er dorthin passen würde:
// Die sind auf kurze Werte ausgelegt (200 Zeichen je Feld), und ein Zettel
// ist Text, kein Schalter.
Note string `json:"note,omitempty"`
// Das Bild — entweder eine Datei neben honeyos.json oder eine fremde
// Adresse, nie beides. `Bildstand` ist der Versionsanteil in der Adresse:
// Ohne ihn bliebe beim Austausch das alte Bild im Zwischenspeicher hängen.
Bild string `json:"bild,omitempty"` // Dateiname in bilder/
BildURL string `json:"bildUrl,omitempty"` // oder eine fremde Adresse
Bildstand string `json:"bildstand,omitempty"` // gegen den Zwischenspeicher
}
type data struct {
@@ -52,6 +67,9 @@ const (
maxApps = 24
maxSettings = 40
maxFieldLen = 200
// Ein Zettel, kein Dokument. Wer mehr braucht, hat InkNote — und der Server
// muss die Grenze durchsetzen, nicht das Formular.
maxNoteLen = 4000
)
var (
@@ -182,6 +200,27 @@ func (s *Store) SeenUser(sub, name, picture string) error {
return s.save()
}
// Note gibt den Zettel des Nutzers. Kein Rückfall auf den Standardnutzer wie
// bei den Einstellungen: Ein fremder Zettel ist kein Ersatz für den eigenen.
func (s *Store) Note(sub string) string {
return s.User(sub).Note
}
// SetNote schreibt den Zettel. Zwei offene Geräte überschreiben einander — der
// letzte Stand gewinnt. Für einen Zettel ist das vertretbar; alles darüber
// bräuchte Versionen und eine Zusammenführung, und dann wäre es keine
// Kleinigkeit mehr, sondern eine Notiz-App.
func (s *Store) SetNote(sub, text string) error {
if len([]rune(text)) > maxNoteLen {
return fmt.Errorf("der Zettel fasst %d Zeichen", maxNoteLen)
}
s.mu.Lock()
defer s.mu.Unlock()
s.userLocked(sub).Note = text
return s.save()
}
func (s *Store) SetSettings(sub string, in map[string]string) error {
if len(in) > maxSettings {
return fmt.Errorf("zu viele Einstellungen")
@@ -254,5 +293,92 @@ func (s *Store) AppOrigins() map[string]bool {
return out
}
/* ── Bilder ────────────────────────────────────────────────────────────────
*
* Das Erste, was HoneyOS als Datei ablegt. Bis hierher war alles in einer
* einzigen JSON-Datei, und das soll auch so bleiben: Die Bilder liegen daneben
* in `bilder/`, die JSON kennt nur ihre Namen. Ein Nutzer hat genau **ein**
* Bild — ein neues ersetzt das alte, samt Datei.
*
* Der Dateiname ist ein Fingerabdruck der Nutzerkennung, keine Kennung im
* Klartext: In einem Ordnernamen hätte eine OIDC-`sub` nichts zu suchen, und
* manche taugen gar nicht als Dateiname.
*/
func (s *Store) bilderDir() string { return filepath.Join(filepath.Dir(s.path), "bilder") }
func bildName(sub, endung string) string {
sum := sha256.Sum256([]byte(sub))
return hex.EncodeToString(sum[:8]) + endung
}
// Bild sagt, was der Nutzer hinterlegt hat: eine Datei, eine Adresse oder
// nichts. `stand` gehört an die Adresse der Datei.
func (s *Store) Bild(sub string) (datei, url, stand string) {
u := s.User(sub)
return u.Bild, u.BildURL, u.Bildstand
}
func (s *Store) BildPfad(sub string) (string, bool) {
datei, _, _ := s.Bild(sub)
if datei == "" {
return "", false
}
return filepath.Join(s.bilderDir(), datei), true
}
// SetBildDatei legt die Bytes ab und macht sie zum Bild des Nutzers. Ein
// vorhandenes Bild wird ersetzt — auch die alte Datei, sonst wächst der Ordner
// mit jedem Austausch.
func (s *Store) SetBildDatei(sub, endung string, daten []byte) error {
if err := os.MkdirAll(s.bilderDir(), 0o755); err != nil {
return fmt.Errorf("bilder-ordner: %w", err)
}
name := bildName(sub, endung)
if err := os.WriteFile(filepath.Join(s.bilderDir(), name), daten, 0o644); err != nil {
return fmt.Errorf("bild schreiben: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
u := s.userLocked(sub)
if u.Bild != "" && u.Bild != name {
os.Remove(filepath.Join(s.bilderDir(), u.Bild))
}
u.Bild, u.BildURL = name, ""
u.Bildstand = strconv.FormatInt(time.Now().Unix(), 36)
return s.save()
}
// SetBildURL nimmt stattdessen eine fremde Adresse. Eine leere löscht alles.
func (s *Store) SetBildURL(sub, url string) error {
if len(url) > maxFieldLen*4 {
return fmt.Errorf("die Adresse ist zu lang")
}
if url != "" && !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
return fmt.Errorf("die Adresse muss mit http:// oder https:// beginnen")
}
s.mu.Lock()
defer s.mu.Unlock()
u := s.userLocked(sub)
if u.Bild != "" {
os.Remove(filepath.Join(s.bilderDir(), u.Bild))
}
u.Bild, u.BildURL = "", url
u.Bildstand = strconv.FormatInt(time.Now().Unix(), 36)
return s.save()
}
// userLocked holt (oder legt an) den Nutzer. Nur mit gehaltenem Schloss.
func (s *Store) userLocked(sub string) *User {
u, ok := s.d.Users[sub]
if !ok {
u = &User{}
s.d.Users[sub] = u
}
return u
}
// DataPath nennt die eine Datei, in der alles steht.
func DataPath(dir string) string { return filepath.Join(dir, "honeyos.json") }
+122
View File
@@ -16,6 +16,7 @@ import (
"crypto/subtle"
"embed"
"encoding/json"
"io"
"io/fs"
"log"
"net/http"
@@ -146,6 +147,19 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /api/me", s.requireAuth(s.handleMe))
mux.HandleFunc("PUT /api/settings", s.requireAuth(s.handleSetSettings))
// Der Zettel — nur für Angemeldete, in beide Richtungen. Er steht bewusst
// nicht in `appsReadable`: Die HoneyBar in einer fremden App weist sich mit
// dem Schlüssel aus, und der kennt keinen Nutzer. Fremde Zettel gehen
// niemanden etwas an.
mux.HandleFunc("GET /api/note", s.requireAuth(s.handleNote))
mux.HandleFunc("PUT /api/note", s.requireAuth(s.handleSetNote))
// Das Bild: nachsehen, hochladen, verlinken, ausliefern. Vier Wege, weil
// „hochgeladene Bytes" und „fremde Adresse" verschiedene Dinge sind und
// eine Abzweigung nach Inhaltstyp im selben Endpunkt nur Rätsel aufgäbe.
mux.HandleFunc("GET /api/image/info", s.requireAuth(s.handleBildInfo))
mux.HandleFunc("POST /api/image", s.requireAuth(s.handleBildHoch))
mux.HandleFunc("PUT /api/image", s.requireAuth(s.handleBildLink))
mux.HandleFunc("GET /api/image", s.requireAuth(s.handleBild))
mux.HandleFunc("GET /api/apps", s.appsReadable(s.handleApps))
mux.HandleFunc("PUT /api/apps", s.requireAuth(s.handleSetApps))
// Dieselbe Tür wie die App-Liste: Die HoneyBar steht in einer fremden App und
@@ -368,6 +382,114 @@ func (s *Server) handleApps(w http.ResponseWriter, r *http.Request, sub string)
// Normalfall, solange niemand einen gewählt hat, und die HoneyBar soll dafür
// nicht rot blinken, sondern das Symbol einfach weglassen. Dasselbe gilt, wenn
// der Dienst nicht erreichbar ist: lieber kein Wetter als eine Zahl von gestern.
func (s *Server) handleNote(w http.ResponseWriter, r *http.Request, sub string) {
writeJSON(w, http.StatusOK, map[string]string{"text": s.store.Note(sub)})
}
func (s *Server) handleSetNote(w http.ResponseWriter, r *http.Request, sub string) {
var in struct {
Text string `json:"text"`
}
if err := decode(r, &in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if err := s.store.SetNote(sub, in.Text); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
/* ── Das Bild ──────────────────────────────────────────────────────────────
*
* Genau eines je Nutzer, entweder hochgeladen oder verlinkt. Der Ausweis ist
* immer die Sitzung: Bilder gehen weder über den App-Schlüssel noch an fremde
* Ursprünge — es ist das persönlichste, was HoneyOS speichert.
*/
// bildTypen: was hereindarf, und mit welcher Endung es abgelegt wird.
//
// **SVG steht bewusst nicht dabei.** Es ist Text, kann Skripte enthalten und
// würde, aus derselben Adresse ausgeliefert, im Namen der Seite laufen. Ein
// Bildwidget ist diesen Weg nicht wert.
var bildTypen = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/gif": ".gif",
}
const maxBild = 3 << 20 // 3 MB
func (s *Server) handleBildInfo(w http.ResponseWriter, r *http.Request, sub string) {
datei, url, stand := s.store.Bild(sub)
switch {
case datei != "":
writeJSON(w, http.StatusOK, map[string]string{"art": "datei", "url": "/api/image?v=" + stand})
case url != "":
writeJSON(w, http.StatusOK, map[string]string{"art": "link", "url": url})
default:
writeJSON(w, http.StatusOK, map[string]string{"art": "", "url": ""})
}
}
func (s *Server) handleBildHoch(w http.ResponseWriter, r *http.Request, sub string) {
defer r.Body.Close()
daten, err := io.ReadAll(io.LimitReader(r.Body, maxBild+1))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Bild nicht lesbar"})
return
}
if len(daten) > maxBild {
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "Das Bild ist größer als 3 MB"})
return
}
// Der eigene Blick auf die Bytes, nicht die Angabe des Browsers: Wer
// hochlädt, bestimmt sonst den Typ — und damit, was ausgeliefert wird.
typ := strings.SplitN(http.DetectContentType(daten), ";", 2)[0]
endung, ok := bildTypen[typ]
if !ok {
writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{
"error": "Nur PNG, JPEG, WebP oder GIF — erkannt wurde " + typ,
})
return
}
if err := s.store.SetBildDatei(sub, endung, daten); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleBildLink(w http.ResponseWriter, r *http.Request, sub string) {
var in struct {
URL string `json:"url"`
}
if err := decode(r, &in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if err := s.store.SetBildURL(sub, strings.TrimSpace(in.URL)); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleBild(w http.ResponseWriter, r *http.Request, sub string) {
pfad, ok := s.store.BildPfad(sub)
if !ok {
http.NotFound(w, r)
return
}
// Der Versionsanteil steht in der Adresse, also darf der Browser es lange
// behalten: Ein neues Bild bekommt eine neue Adresse.
w.Header().Set("Cache-Control", "private, max-age=604800")
http.ServeFile(w, r, pfad)
}
func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request, sub string) {
set := s.store.SettingsFor(sub)
lat, err1 := strconv.ParseFloat(set["weatherLat"], 64)
+111
View File
@@ -1115,6 +1115,117 @@ body {
.w-zustand { color: var(--p-dim); margin: 0; font-size: 12.5px; }
.w-fehler { color: var(--p-mid); }
/* ── Zettel ────────────────────────────────────────────────────────────────
* Gelesen wird gesetzt, geschrieben wird roh. Beide Ansichten sind gleich
* hoch, damit der Kasten beim Umschalten nicht springt. */
.zettel-text,
.zettel-feld {
min-height: 112px;
max-height: 320px;
overflow-y: auto;
}
.zettel-feld {
display: block;
width: 100%;
background: var(--bg);
border: 1px solid var(--p-dim);
color: var(--p);
font: inherit;
font-size: 13px;
line-height: 1.45;
padding: 8px;
resize: vertical;
}
.zettel-feld:focus { outline: none; border-color: var(--p); }
.zettel-text { font-size: 13px; line-height: 1.45; color: var(--p-mid); }
.zettel-text h3,
.zettel-text h4,
.zettel-text h5 {
margin: 10px 0 4px;
color: var(--p);
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.zettel-text h3 { font-size: 13px; }
.zettel-text h4 { font-size: 12px; }
.zettel-text h5 { font-size: 11px; color: var(--p-mid); }
.zettel-text > :first-child { margin-top: 0; }
.zettel-text p { margin: 0 0 6px; }
.zettel-text ul,
.zettel-text ol { margin: 0 0 6px; padding-left: 18px; }
.zettel-text li { margin: 1px 0; }
.zettel-text code {
background: var(--p-faint);
padding: 0 3px;
color: var(--p);
}
.zettel-text b { color: var(--p); font-weight: 400; }
.zettel-text hr { border: 0; border-top: 1px solid var(--p-faint); margin: 8px 0; }
/* Häkchen: anklickbar, ohne in den Text zu springen. Die Aufzählungspunkte
fallen weg — das Kästchen ist der Punkt. */
.zettel-text .zettel-hak { list-style: none; margin-left: -18px; }
.zettel-text .zettel-hak label {
display: flex;
align-items: baseline;
gap: 7px;
cursor: pointer;
}
.zettel-text .zettel-hak input {
appearance: none;
flex: none;
width: 11px;
height: 11px;
border: 1px solid var(--p-dim);
background: var(--bg);
cursor: pointer;
position: relative;
top: 1px;
}
.zettel-text .zettel-hak input:checked { background: var(--p); border-color: var(--p); }
.zettel-text .zettel-hak.ab span { color: var(--p-dim); text-decoration: line-through; }
.zettel-fuss {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
}
.zettel-stand { color: var(--p-dim); font-size: 11px; }
.zettel-fuss .btn { font-size: 12px; padding: 3px 9px; }
/* ── Bild ──────────────────────────────────────────────────────────────────
* Fester Rahmen, das Bild füllt ihn. Verzerren wäre die Alternative — und
* Zuschneiden von Hand ein eigenes Stück Arbeit. */
.bildw {
border: 1px solid var(--p-faint);
background: var(--bg);
aspect-ratio: 16 / 10;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.bildw-bild { width: 100%; height: 100%; object-fit: cover; display: block; }
.bildw .w-zustand { padding: 0 12px; text-align: center; }
.bildw-linkzeile { display: flex; gap: 6px; margin-top: 8px; }
.bildw-linkzeile input {
flex: 1;
min-width: 0;
background: var(--bg);
border: 1px solid var(--p-dim);
color: var(--p);
font: inherit;
font-size: 12px;
padding: 4px 6px;
}
.bildw-linkzeile input:focus { outline: none; border-color: var(--p); }
.bildw-knoepfe { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
/* Einspaltig (Handy, schmales Fenster) sollen die Widgets so breit sein wie das
Systemfenster. Drei verschieden breite Kästen untereinander sehen aus wie ein
Fehler, nicht wie Absicht. Muss hinter den Regeln stehen, die es aufhebt —
+3 -1
View File
@@ -53,7 +53,9 @@ export async function api(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
if (APPS_KEY) headers["X-HoneyOS-Key"] = APPS_KEY;
if (opts.method && opts.method !== "GET") {
headers["Content-Type"] = "application/json";
// Nur voreinstellen, nicht überschreiben: Das Bild-Widget schickt Bytes und
// nennt deren Typ selbst. Vorher log der Aufrufhelfer den Server an.
if (!headers["Content-Type"]) headers["Content-Type"] = "application/json";
const tok = cookie("honeyos_csrf");
if (tok) headers["X-CSRF-Token"] = tok;
}
+3 -1
View File
@@ -8,12 +8,14 @@ import { widgetsLaden, widgetsOrdnen, widgetsStarten, widgetsZeichnen } from "./
import "./widgets/user.js";
import { statsHtml } from "./widgets/system.js";
import { loadWeather, renderWeatherBar } from "./widgets/weather.js";
import "./widgets/notiz.js";
import "./widgets/bild.js";
// Die Reihenfolge steht hier und nicht in den Importzeilen darüber: Module
// werden nicht in deren Reihenfolge ausgewertet, sobald eines auf oberster
// Ebene wartet (glyphs.js holt seine Raster mit await). Bis die Reihenfolge
// aus dem Server kommt, ist das ihre Stelle.
widgetsOrdnen(["benutzer", "system", "wetter"]);
widgetsOrdnen(["benutzer", "system", "wetter", "notiz", "bild"]);
/*
* HoneyOS — Desktop-Logik.
+134
View File
@@ -0,0 +1,134 @@
import { api, escapeAttr, escapeHtml } from "../core.js";
import { widgetAnmelden, widgetsLaden, widgetsZeichnen } from "./rahmen.js";
/* ── Das Bild ──────────────────────────────────────────────────────────────
*
* Genau **ein** Bild je Nutzer, hochgeladen oder verlinkt. Ein neues ersetzt
* das alte, ohne Verwaltung und ohne Liste — das ist der ganze Reiz: Es ist
* das erste Widget, das nichts abfragt, sondern etwas *ist*.
*
* Hochgeladen wird zum eigenen Server; verlinkt wird auf eine fremde Adresse.
* Der Unterschied ist keiner der Bequemlichkeit: Ein Link holt bei jedem
* Aufbau des Schreibtischs etwas von woanders, ein Upload nie.
*/
const LEER = "Kein Bild — hochladen oder Adresse einfügen.";
let art = ""; // "datei" | "link" | ""
let quelle = "";
let stand = ""; // Meldung unter dem Bild
let linkAuf = false;
widgetAnmelden({
id: "bild",
titel: "bild",
// Hält ein Dateifeld und ein Eingabefeld: Der Rahmen darf den Inhalt nicht
// unter den Fingern austauschen.
stabil: true,
async laden() {
const d = await api("/api/image/info");
art = (d && d.art) || "";
quelle = (d && d.url) || "";
},
inhalt() {
const bild = quelle
? `<img class="bildw-bild" src="${escapeAttr(quelle)}" alt="" />`
: `<p class="w-zustand">${LEER}</p>`;
return `
<div class="bildw">${bild}</div>
${
linkAuf
? `<div class="bildw-linkzeile">
<input class="bildw-url" type="url" spellcheck="false"
placeholder="https://…" value="${escapeAttr(art === "link" ? quelle : "")}" />
<button class="btn bildw-uebernehmen">setzen</button>
</div>`
: ""
}
<div class="zettel-fuss">
<span class="zettel-stand">${escapeHtml(stand)}</span>
<span class="bildw-knoepfe">
<input class="bildw-datei" type="file" accept="image/png,image/jpeg,image/webp,image/gif" hidden />
<button class="btn bildw-waehlen">${quelle ? "ersetzen" : "hochladen"}</button>
<button class="btn bildw-link">link</button>
${quelle ? `<button class="btn bildw-weg">entfernen</button>` : ""}
</span>
</div>`;
},
montiert(el) {
const datei = el.querySelector(".bildw-datei");
el.querySelector(".bildw-waehlen").addEventListener("click", () => datei.click());
datei.addEventListener("change", () => datei.files[0] && hochladen(datei.files[0]));
el.querySelector(".bildw-link").addEventListener("click", () => {
linkAuf = !linkAuf;
neu();
});
const url = el.querySelector(".bildw-url");
if (url) {
url.focus();
const setzen = () => linkSetzen(url.value.trim());
el.querySelector(".bildw-uebernehmen").addEventListener("click", setzen);
url.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
setzen();
}
});
}
const weg = el.querySelector(".bildw-weg");
if (weg) weg.addEventListener("click", () => linkSetzen(""));
// Ein Bild, das nicht kommt, soll es sagen — bei einem Link ist das der
// Normalfall des Tages, an dem die fremde Seite umzieht.
const img = el.querySelector(".bildw-bild");
if (img) {
img.addEventListener("error", () => {
melden(art === "link" ? "Adresse liefert kein Bild" : "Bild nicht lesbar");
});
}
},
});
async function hochladen(file) {
melden("lädt hoch …");
try {
await api("/api/image", { method: "POST", body: file, headers: { "Content-Type": file.type || "application/octet-stream" } });
linkAuf = false;
await widgetsLaden("bild");
melden("");
} catch (e) {
melden(e.message);
}
}
async function linkSetzen(adresse) {
melden(adresse ? "übernehme …" : "entferne …");
try {
await api("/api/image", { method: "PUT", body: JSON.stringify({ url: adresse }) });
linkAuf = false;
await widgetsLaden("bild");
melden("");
} catch (e) {
melden(e.message);
}
}
function melden(s) {
stand = s;
document.querySelectorAll('[data-widget="bild"] .zettel-stand').forEach((el) => {
el.textContent = s;
});
}
/** Neu zeichnen — stabile Widgets lässt der Rahmen sonst in Ruhe. */
function neu() {
document.querySelectorAll('[data-widget="bild"]').forEach((el) => delete el.dataset.zustand);
widgetsZeichnen();
}
+254
View File
@@ -0,0 +1,254 @@
import { api, escapeHtml } from "../core.js";
import { widgetAnmelden, widgetsZeichnen } from "./rahmen.js";
/* ── Der Zettel ────────────────────────────────────────────────────────────
*
* Ein Textfeld, mehr nicht. **InkNote gibt es schon** — der Zettel darf keine
* zweite Notiz-App werden: kein Titel, keine Liste, keine Ordner, keine Suche.
* Genau ein Text, den man im Vorbeigehen benutzt: Einkaufszettel, eine Nummer,
* ein Gedanke. Alles, was länger als einen Tag lebt, gehört nach InkNote.
*
* Zwei Ansichten: gelesen wird gesetzt, geschrieben wird roh. Der Umweg über
* einen Knopf ist Absicht — ein Feld, das immer im Bearbeitungsmodus steht,
* frisst jeden Klick, den man eigentlich dem Häkchen zugedacht hatte.
*
* Häkchen lassen sich **ohne** Bearbeiten setzen. Das ist der häufigste Griff
* an einer Liste, und dafür erst in den Text zu springen wäre albern.
*/
const LEER = "Noch nichts notiert.";
const VERZUG = 800; // ms bis zum Speichern beim Tippen
let text = "";
let bearbeiten = false;
let stand = ""; // "speichert …" | "gespeichert" | Fehlertext
let uhr = null;
widgetAnmelden({
id: "notiz",
titel: "zettel",
// Der Zettel hält ein Textfeld: Sein Inhalt darf nicht bei jedem Neuzeichnen
// ersetzt werden, sonst verliert man beim Tippen Text und Schreibmarke.
stabil: true,
async laden() {
const d = await api("/api/note");
text = (d && d.text) || "";
},
inhalt() {
if (bearbeiten) {
return `
<textarea class="zettel-feld" spellcheck="false"
placeholder="# Einkauf&#10;- [ ] Milch&#10;- [x] Brot">${escapeHtml(text)}</textarea>
<div class="zettel-fuss">
<span class="zettel-stand">${escapeHtml(stand)}</span>
<button class="btn zettel-fertig">fertig</button>
</div>`;
}
return `
<div class="zettel-text">${text.trim() ? markdown(text) : `<p class="w-zustand">${LEER}</p>`}</div>
<div class="zettel-fuss">
<span class="zettel-stand">${escapeHtml(stand)}</span>
<button class="btn zettel-auf">${text.trim() ? "bearbeiten" : "schreiben"}</button>
</div>`;
},
montiert(el) {
const feld = el.querySelector(".zettel-feld");
if (feld) {
feld.focus();
// Schreibmarke ans Ende, nicht an den Anfang: Man schreibt weiter, man
// fängt selten neu an.
feld.setSelectionRange(feld.value.length, feld.value.length);
feld.addEventListener("input", () => {
text = feld.value;
spaeterSpeichern();
});
// Beim Verlassen sofort, nicht erst nach dem Verzug — sonst ginge der
// letzte Satz verloren, wenn man den Tab zumacht.
feld.addEventListener("blur", jetztSpeichern);
el.querySelector(".zettel-fertig").addEventListener("click", () => {
bearbeiten = false;
jetztSpeichern();
neu();
});
return;
}
const auf = el.querySelector(".zettel-auf");
if (auf) {
auf.addEventListener("click", () => {
bearbeiten = true;
neu();
});
}
// Häkchen im gesetzten Text — sie ändern die Quelle, nicht nur die Anzeige.
el.querySelectorAll("[data-hak]").forEach((box) =>
box.addEventListener("change", () => {
text = hakenUmlegen(text, Number(box.dataset.hak));
neu();
jetztSpeichern();
})
);
},
});
/** Neu zeichnen — der Rahmen lässt stabile Widgets sonst in Ruhe. */
function neu() {
const koerper = document.querySelectorAll('[data-widget="notiz"]');
koerper.forEach((el) => delete el.dataset.zustand);
widgetsZeichnen();
}
function spaeterSpeichern() {
stand = "";
clearTimeout(uhr);
uhr = setTimeout(jetztSpeichern, VERZUG);
}
/**
* Speichert, wenn sich etwas geändert hat.
*
* Zwei offene Geräte überschreiben einander; der letzte Stand gewinnt. Für
* einen Zettel ist das vertretbar — man sollte es nur wissen und nicht später
* „Datenverlust" nennen.
*/
let gespeichert = null;
async function jetztSpeichern() {
clearTimeout(uhr);
if (text === gespeichert) return;
const wollte = text;
standSetzen("speichert …");
try {
await api("/api/note", { method: "PUT", body: JSON.stringify({ text: wollte }) });
gespeichert = wollte;
standSetzen("gespeichert");
} catch (e) {
standSetzen(e.message || "nicht gespeichert");
}
}
function standSetzen(s) {
stand = s;
document.querySelectorAll('[data-widget="notiz"] .zettel-stand').forEach((el) => {
el.textContent = s;
});
}
/* ── Das kleine Markdown ───────────────────────────────────────────────────
*
* Absichtlich von Hand und absichtlich klein: Überschriften, Häkchen, Striche,
* Aufzählungen, fett, kursiv, Code. Keine Bibliothek — HoneyOS hat keinen
* Bauschritt, und für sechs Regeln lohnt kein Nachladen.
*
* **Zuerst wird escapt, dann gesetzt.** Der Text ist zwar der eigene, aber ein
* Zettel, der `<script>` ausführt, wäre eine Falle für den Tag, an dem man
* etwas hineinkopiert.
*/
export function markdown(quelle) {
const zeilen = quelle.split("\n");
let html = "";
let liste = null; // "ul" | "ol" | null
let hakenNr = -1; // fortlaufend über den ganzen Text, für data-hak
const listeZu = () => {
if (liste) html += `</${liste}>`;
liste = null;
};
const listeAuf = (art) => {
if (liste !== art) {
listeZu();
html += `<${art}>`;
liste = art;
}
};
zeilen.forEach((roh) => {
const z = roh.trimEnd();
if (!z.trim()) {
listeZu();
return;
}
const ueber = /^(#{1,3})\s+(.*)$/.exec(z);
if (ueber) {
listeZu();
const n = ueber[1].length + 2; // # → h3, ## → h4, ### → h5
html += `<h${n}>${inline(ueber[2])}</h${n}>`;
return;
}
if (/^(-{3,}|\*{3,})$/.test(z.trim())) {
listeZu();
html += "<hr />";
return;
}
const hak = /^\s*[-*]\s+\[([ xX])\]\s+(.*)$/.exec(z);
if (hak) {
hakenNr++;
listeAuf("ul");
const an = hak[1].toLowerCase() === "x";
html +=
`<li class="zettel-hak${an ? " ab" : ""}">` +
`<label><input type="checkbox" data-hak="${hakenNr}"${an ? " checked" : ""} />` +
`<span>${inline(hak[2])}</span></label></li>`;
return;
}
const punkt = /^\s*[-*]\s+(.*)$/.exec(z);
if (punkt) {
listeAuf("ul");
html += `<li>${inline(punkt[1])}</li>`;
return;
}
const zahl = /^\s*\d+[.)]\s+(.*)$/.exec(z);
if (zahl) {
listeAuf("ol");
html += `<li>${inline(zahl[1])}</li>`;
return;
}
listeZu();
html += `<p>${inline(z)}</p>`;
});
listeZu();
return html;
}
function inline(s) {
return escapeHtml(s)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, "$1<i>$2</i>");
}
/** Legt das n-te Häkchen im Quelltext um — gezählt wie beim Setzen. */
export function hakenUmlegen(quelle, n) {
let i = -1;
return quelle
.split("\n")
.map((z) => {
const hak = /^(\s*[-*]\s+\[)([ xX])(\]\s+.*)$/.exec(z);
if (!hak) return z;
i++;
if (i !== n) return z;
return hak[1] + (hak[2].toLowerCase() === "x" ? " " : "x") + hak[3];
})
.join("\n");
}
// Beim Verlassen der Seite noch schnell sichern — der Verzug beim Tippen darf
// keinen Satz kosten, nur weil der Tab zugeht.
//
// `keepalive` statt sendBeacon: Der Beacon kann nur POST und trägt keine
// eigenen Kopfzeilen — unser Weg ist ein PUT mit Prüfwort. Ein Beacon wäre
// hier also nicht schneller, sondern schlicht abgewiesen worden.
window.addEventListener("pagehide", () => {
if (text === gespeichert) return;
api("/api/note", { method: "PUT", body: JSON.stringify({ text }), keepalive: true }).catch(() => {});
});
+55 -18
View File
@@ -114,9 +114,59 @@ function zustandHtml(w) {
/**
* Zeichnet alle Widgets in die Ansicht, die gerade gilt.
*
* Beide Container werden bei jedem Durchgang neu gefüllt statt einzeln
* nachgeführt: Bei einer Handvoll Widgets ist das billiger als Buchführung
* darüber, was sich geändert hat — und es kann nicht auseinanderlaufen.
* Jedes Widget bekommt seinen eigenen Knoten, der stehen bleibt, solange es
* gebraucht wird. Der erste Entwurf hat beide Behälter bei jedem Durchgang neu
* gefüllt — kürzer, aber falsch, sobald ein Widget etwas im DOM hält: Der
* Zettel wird alle zwei Minuten mitgezeichnet (die Apps werden dann angeklopft),
* und wer gerade tippte, verlor Text und Schreibmarke.
*
* Deshalb zwei Zusagen an die Widgets:
* · `stabil: true` — dein Inhalt wird nicht neu gesetzt, solange sich dein
* Zustand nicht ändert. Du führst ihn selbst nach.
* · `montiert(el)` — wird nach jedem Neusetzen gerufen, zum Verdrahten.
*/
function baue(behaelter, dran, alsFenster) {
const alt = new Map([...behaelter.children].map((el) => [el.dataset.widget, el]));
dran.forEach((w) => {
let el = alt.get(w.id);
alt.delete(w.id);
if (!el) {
el = document.createElement(alsFenster ? "section" : "div");
el.dataset.widget = w.id;
el.className = alsFenster ? "win win-widget" : "deskwidget";
if (alsFenster) {
el.innerHTML =
`<div class="win-bar"><span class="win-title">${w.titel}</span>` +
`<span class="win-dots">&#9679;&#9679;&#9679;</span></div><div class="win-body"></div>`;
}
behaelter.appendChild(el);
}
const koerper = alsFenster ? el.querySelector(".win-body") : el;
const frisch = el.dataset.zustand !== zustand[w.id];
if (frisch || !w.stabil) {
koerper.innerHTML = zustandHtml(w);
el.dataset.zustand = zustand[w.id];
if (w.montiert && zustand[w.id] === "fertig") w.montiert(koerper);
}
});
alt.forEach((el) => el.remove());
// Reihenfolge nur anfassen, wenn sie nicht stimmt: Einen Knoten zu verschieben
// heißt, ihn kurz aus dem Dokument zu nehmen — ein Textfeld verliert dabei
// die Schreibmarke.
const soll = dran.map((w) => w.id).join(",");
const ist = [...behaelter.children].map((el) => el.dataset.widget).join(",");
if (soll !== ist) {
dran.forEach((w) => behaelter.appendChild(behaelter.querySelector(`[data-widget="${w.id}"]`)));
}
}
/**
* Wählt aus, was gerade dran ist, und lässt es zeichnen.
*/
export function widgetsZeichnen() {
const desktop = state.settings.mode === "desktop";
@@ -133,19 +183,6 @@ export function widgetsZeichnen() {
return !(zustand[w.id] === "leer" && w.leer === "verbergen");
});
spalte.innerHTML = desktop
? ""
: dran
.map(
(w) => `
<section class="win win-widget" data-widget="${w.id}">
<div class="win-bar"><span class="win-title">${w.titel}</span><span class="win-dots">&#9679;&#9679;&#9679;</span></div>
<div class="win-body">${zustandHtml(w)}</div>
</section>`
)
.join("");
schilder.innerHTML = desktop
? dran.map((w) => `<div class="deskwidget" data-widget="${w.id}">${zustandHtml(w)}</div>`).join("")
: "";
baue(spalte, desktop ? [] : dran, true);
baue(schilder, desktop ? dran : [], false);
}
+49
View File
@@ -8,6 +8,7 @@ import (
"strings"
"testing"
"honeyos/internal/auth"
"honeyos/internal/store"
"honeyos/internal/weather"
)
@@ -128,3 +129,51 @@ func TestHoneyBarOhneBacktickInKommentaren(t *testing.T) {
}
}
}
// Der Zettel ist ein Zettel, kein Dokument: Die Grenze muss der Server ziehen,
// nicht das Formular — sonst schreibt sie jeder Aufruf aus, der das Formular
// umgeht.
func TestZettelHatEineGrenze(t *testing.T) {
s := newTestServer(t, "")
h := s.routes()
put := func(text string) int {
body, _ := json.Marshal(map[string]string{"text": text})
req := httptest.NewRequest("PUT", "/api/note", strings.NewReader(string(body)))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code
}
if code := put("# Einkauf\n- [ ] Milch"); code != http.StatusNoContent {
t.Fatalf("kurzer Zettel: %d, erwartet 204", code)
}
if code := put(strings.Repeat("x", 4001)); code != http.StatusBadRequest {
t.Errorf("zu langer Zettel: %d, erwartet 400", code)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/note", nil))
var out map[string]string
json.Unmarshal(rec.Body.Bytes(), &out)
if out["text"] != "# Einkauf\n- [ ] Milch" {
t.Errorf("zurück kam %q — der zu lange Zettel darf den guten nicht überschrieben haben", out["text"])
}
}
// Fremde Zettel gehen niemanden etwas an: Der Schlüssel für die App-Liste darf
// sie nicht öffnen, auch nicht lesend.
func TestZettelNichtUeberDenSchluessel(t *testing.T) {
idp := newFakeIdP(t, "honeyos")
s := newTestServer(t, idp.URL)
s.cfg.AppsKey = "geheim123"
s.auth = auth.New(s.cfg)
req := httptest.NewRequest("GET", "/api/note", nil)
req.Header.Set("X-HoneyOS-Key", "geheim123")
rec := httptest.NewRecorder()
s.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("mit Schlüssel: %d, erwartet 401", rec.Code)
}
}