Merge branch 'dev' into main: v0.11.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 24s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
d70d141e9a
68 files changed
+2727
-518
No files matched your search
@@ -75,6 +75,13 @@ SMTP_TLS=true
|
||||
# true = selbstsignierte Zertifikate akzeptieren (nur für interne Mailserver).
|
||||
SMTP_SKIP_VERIFY=false
|
||||
|
||||
# Web-Push (Benachrichtigungen auf Handy und Desktop, auch bei geschlossener App).
|
||||
# Ohne Angabe erzeugt MindSift beim ersten Bedarf ein Schlüsselpaar und speichert es in der Datenbank.
|
||||
# VAPID_SUBJECT: Kontakt für die Push-Dienste (mailto: oder https://); Standard ist BASE_URL, wenn https.
|
||||
# VAPID_PUBLIC_KEY=
|
||||
# VAPID_PRIVATE_KEY=
|
||||
# VAPID_SUBJECT=mailto:admin@example.com
|
||||
|
||||
# ─── Impressum und Datenschutz (optional) ──────────────────────────────────────────────────────────
|
||||
# Werden auf der Anmeldeseite und in den Einstellungen verlinkt. Entweder als Link auf eine externe
|
||||
# Seite (nur http/https) oder als Markdown-Text, direkt hier oder als Datei (per Volume einbinden).
|
||||
@@ -86,6 +93,9 @@ LEGAL_PRIVACY_CONTENT=
|
||||
# Pfad im Container, z. B. /legal/impressum.md
|
||||
LEGAL_IMPRINT_CONTENT_FILE=
|
||||
LEGAL_PRIVACY_CONTENT_FILE=
|
||||
# Eigener Link im Footer (nur https://)
|
||||
LEGAL_CUSTOM_LINK_LABEL=
|
||||
LEGAL_CUSTOM_LINK_URL=
|
||||
|
||||
# ─── Bilder und Audio in Notizen ───────────────────────────────────────────────────────────────────
|
||||
# Dateien liegen in data/media/ und sind nur für Personen sichtbar, die die Notiz lesen dürfen. Nicht mehr
|
||||
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# Decides per CI run: build with tests, build without tests, or only retag an existing image.
|
||||
# Tests run once per code state: the images carry the git tree and commit as labels
|
||||
# (io.mindsift.tree, org.opencontainers.image.revision), so no extra image tags are needed.
|
||||
# dev -> build and test, tag :dev
|
||||
# main -> build :latest; tests are skipped when :dev was built from the same tree
|
||||
# v* -> retag :latest when it was built from this commit, else build (tests skipped when
|
||||
# :dev or :latest has the same tree); :stable only for releases without "-"
|
||||
# Inputs (environment): REF, COMMIT (default HEAD), IMAGE, PKG_VERSION (default from package.json),
|
||||
# LABEL_CMD (prints a label of a remote image: LABEL_CMD <image> <label>; for tests)
|
||||
set -euo pipefail
|
||||
|
||||
REF="${REF:-}"
|
||||
COMMIT="${COMMIT:-HEAD}"
|
||||
IMAGE="${IMAGE:-}"
|
||||
PKG_VERSION="${PKG_VERSION:-}"
|
||||
if [ -z "${PKG_VERSION}" ] && [ -f frontend/package.json ]; then
|
||||
PKG_VERSION="$(sed -nE 's/^ "version": "([^"]+)".*/\1/p' frontend/package.json | head -n1)"
|
||||
fi
|
||||
|
||||
SHA="$(git rev-parse "${COMMIT}^{commit}")"
|
||||
TREE="$(git rev-parse "${COMMIT}^{tree}")"
|
||||
|
||||
# Label of an image in the registry ("" when the image or label does not exist)
|
||||
image_label() {
|
||||
if [ -n "${LABEL_CMD:-}" ]; then
|
||||
${LABEL_CMD} "$1" "$2" 2>/dev/null || true
|
||||
return
|
||||
fi
|
||||
docker pull -q "$1" >/dev/null 2>&1 || return 0
|
||||
docker inspect -f "{{ index .Config.Labels \"$2\" }}" "$1" 2>/dev/null | sed 's/^<no value>$//' || true
|
||||
}
|
||||
|
||||
ACTION="build"
|
||||
SKIP_TESTS="0"
|
||||
APP_VERSION=""
|
||||
TAGS=""
|
||||
SOURCE_IMAGE=""
|
||||
REASON=""
|
||||
|
||||
case "${REF}" in
|
||||
refs/heads/dev)
|
||||
TAGS="dev"
|
||||
REASON="dev: build with all tests"
|
||||
;;
|
||||
refs/heads/main)
|
||||
TAGS="latest"
|
||||
APP_VERSION="v${PKG_VERSION}"
|
||||
if [ -n "${IMAGE}" ] && [ "$(image_label "${IMAGE}:dev" io.mindsift.tree)" = "${TREE}" ]; then
|
||||
SKIP_TESTS="1"
|
||||
REASON="main: same code as the tested :dev image, tests skipped"
|
||||
else
|
||||
REASON="main: code not tested as :dev yet, build with all tests"
|
||||
fi
|
||||
;;
|
||||
refs/tags/v*)
|
||||
TAG_NAME="${REF#refs/tags/}"
|
||||
TAGS="${TAG_NAME}"
|
||||
case "${TAG_NAME}" in *-*) ;; *) TAGS="${TAG_NAME} stable" ;; esac
|
||||
if [ -n "${IMAGE}" ] && [ "$(image_label "${IMAGE}:latest" org.opencontainers.image.revision)" = "${SHA}" ]; then
|
||||
ACTION="retag"
|
||||
SOURCE_IMAGE="${IMAGE}:latest"
|
||||
SKIP_TESTS="1"
|
||||
REASON="tag ${TAG_NAME}: :latest was built from this commit, retag without building"
|
||||
else
|
||||
APP_VERSION="${TAG_NAME}"
|
||||
if [ -n "${IMAGE}" ] && { [ "$(image_label "${IMAGE}:dev" io.mindsift.tree)" = "${TREE}" ] ||
|
||||
[ "$(image_label "${IMAGE}:latest" io.mindsift.tree)" = "${TREE}" ]; }; then
|
||||
SKIP_TESTS="1"
|
||||
REASON="tag ${TAG_NAME}: code already tested, build without tests"
|
||||
else
|
||||
REASON="tag ${TAG_NAME}: build with all tests"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
TAGS="$(echo "${REF#refs/heads/}" | sed 's#/#-#g' | tr '[:upper:]' '[:lower:]')"
|
||||
REASON="branch ${REF}: build with all tests"
|
||||
;;
|
||||
esac
|
||||
|
||||
{
|
||||
echo "action=${ACTION}"
|
||||
echo "skip_tests=${SKIP_TESTS}"
|
||||
echo "app_version=${APP_VERSION}"
|
||||
echo "tags=${TAGS}"
|
||||
echo "source_image=${SOURCE_IMAGE}"
|
||||
echo "sha=${SHA}"
|
||||
echo "tree=${TREE}"
|
||||
echo "reason=${REASON}"
|
||||
} | tee -a "${GITHUB_OUTPUT:-/dev/null}"
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Checks build-plan.sh against a fake registry (labels given per case). Usage: .gitea/scripts/test-build-plan.sh
|
||||
set -euo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
PLAN=.gitea/scripts/build-plan.sh
|
||||
IMAGE=registry.example/mindsift
|
||||
SHA="$(git rev-parse HEAD)"
|
||||
TREE="$(git rev-parse HEAD^{tree})"
|
||||
MOCK="$(mktemp)"
|
||||
trap 'rm -f "$MOCK"' EXIT
|
||||
# The fake registry answers from $DEV_TREE, $LATEST_TREE and $LATEST_SHA
|
||||
cat >"$MOCK" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
case "$1 $2" in
|
||||
*:dev\ io.mindsift.tree) echo "${DEV_TREE:-}" ;;
|
||||
*:latest\ io.mindsift.tree) echo "${LATEST_TREE:-}" ;;
|
||||
*:latest\ org.opencontainers.image.revision) echo "${LATEST_SHA:-}" ;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$MOCK"
|
||||
|
||||
fails=0
|
||||
expect() { # name, REF, expected "action skip_tests tags", env assignments…
|
||||
local name="$1" ref="$2" want="$3"
|
||||
shift 3
|
||||
local out
|
||||
out="$(env "$@" REF="$ref" IMAGE="$IMAGE" PKG_VERSION=0.10.0 LABEL_CMD="$MOCK" GITHUB_OUTPUT= "$PLAN")"
|
||||
local got
|
||||
got="$(echo "$out" | sed -n 's/^action=//p') $(echo "$out" | sed -n 's/^skip_tests=//p') $(echo "$out" | sed -n 's/^tags=//p')"
|
||||
if [ "$got" = "$want" ]; then
|
||||
echo "OK $name: $got"
|
||||
else
|
||||
echo "FEHL $name: erwartet '$want', bekommen '$got'"
|
||||
fails=$((fails + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
expect "dev baut mit Tests" refs/heads/dev "build 0 dev"
|
||||
expect "main nach getestetem dev" refs/heads/main "build 1 latest" DEV_TREE="$TREE"
|
||||
expect "main mit anderem Stand" refs/heads/main "build 0 latest" DEV_TREE=0000
|
||||
expect "Tag auf latest-Commit" refs/tags/v0.10.0 "retag 1 v0.10.0 stable" LATEST_SHA="$SHA" LATEST_TREE="$TREE"
|
||||
expect "Tag, latest fehlt, dev getestet" refs/tags/v0.10.0 "build 1 v0.10.0 stable" DEV_TREE="$TREE"
|
||||
expect "Tag auf unbekanntem Stand" refs/tags/v0.10.0 "build 0 v0.10.0 stable"
|
||||
expect "Vorabversion ohne stable" refs/tags/v0.11.0-rc.1 "retag 1 v0.11.0-rc.1" LATEST_SHA="$SHA"
|
||||
expect "Feature-Branch" refs/heads/feat/x "build 0 feat-x"
|
||||
|
||||
if [ "$fails" -gt 0 ]; then
|
||||
echo "$fails FEHLGESCHLAGEN"
|
||||
exit 1
|
||||
fi
|
||||
echo "ALLE FÄLLE BESTANDEN"
|
||||
@@ -5,6 +5,9 @@ name: MindSift CI & Docker Release
|
||||
# push/merge main -> :latest
|
||||
# git tag v1.2.0 -> :v1.2.0 and :stable
|
||||
# git tag v1.2.0-rc.1 (pre-release) -> :v1.2.0-rc.1 only, :stable stays
|
||||
# Tests run once per code state (.gitea/scripts/build-plan.sh): main skips them when :dev was built
|
||||
# from the same code, a tag on the commit of :latest only retags it. The images carry commit and git
|
||||
# tree as labels for this, so no extra tags end up in the registry.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -21,45 +24,16 @@ jobs:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Determine Registry & Docker Tag
|
||||
- name: Determine Registry & Image
|
||||
id: prep
|
||||
run: |
|
||||
SERVER_URL="${GITHUB_SERVER_URL:-https://git.dsit.me}"
|
||||
REGISTRY_HOST=$(echo "${SERVER_URL}" | sed -E 's#^https?://##' | sed 's#/$##')
|
||||
if [ -z "${REGISTRY_HOST}" ]; then
|
||||
REGISTRY_HOST="git.dsit.me"
|
||||
fi
|
||||
|
||||
[ -z "${REGISTRY_HOST}" ] && REGISTRY_HOST="git.dsit.me"
|
||||
REPO_PATH="${GITHUB_REPOSITORY:-sandrodach/mindsift}"
|
||||
IMAGE_NAME=$(echo "${REGISTRY_HOST}/${REPO_PATH}" | tr '[:upper:]' '[:lower:]')
|
||||
REF_NAME="${GITHUB_REF_NAME:-latest}"
|
||||
ACTOR_NAME="${GITHUB_ACTOR:-sandrodach}"
|
||||
|
||||
# Ersetze evtl. Slashes in Branch-Namen (z.B. rc/1.1 -> rc-1.1) für valide Docker-Tags
|
||||
DOCKER_TAG=$(echo "${REF_NAME}" | sed 's#/#-#g' | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
# All tags of this run (space separated)
|
||||
case "${GITHUB_REF:-}" in
|
||||
refs/tags/*-*) TAGS="${DOCKER_TAG}" ;;
|
||||
refs/tags/*) TAGS="${DOCKER_TAG} stable" ;;
|
||||
refs/heads/main) TAGS="latest" ;;
|
||||
*) TAGS="${DOCKER_TAG}" ;;
|
||||
esac
|
||||
|
||||
echo "registry=${REGISTRY_HOST}" >> "$GITHUB_OUTPUT"
|
||||
echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAGS%% *}" >> "$GITHUB_OUTPUT"
|
||||
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
|
||||
echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT"
|
||||
# Version in the app header: tags show the tag, main the package version, everything else
|
||||
# (dev, feature branches) keeps the "-dev" suffix from vite.config.ts
|
||||
APP_VERSION=""
|
||||
case "${GITHUB_REF:-}" in
|
||||
refs/tags/*) APP_VERSION="${REF_NAME}" ;;
|
||||
refs/heads/main) APP_VERSION="v$(sed -nE 's/^ "version": "([^"]+)".*/\1/p' frontend/package.json | head -n1)" ;;
|
||||
esac
|
||||
echo "app_version=${APP_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Target Docker image: ${IMAGE_NAME} tags: ${TAGS} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})"
|
||||
echo "image=$(echo "${REGISTRY_HOST}/${REPO_PATH}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
echo "actor=${GITHUB_ACTOR:-sandrodach}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -68,12 +42,30 @@ jobs:
|
||||
username: ${{ steps.prep.outputs.actor }}
|
||||
password: ${{ secrets.PUSH_TOKEN || secrets.GITEA_TOKEN || github.token }}
|
||||
|
||||
- name: Build & Run All Checks in Container, then Push
|
||||
- name: Build Plan
|
||||
id: plan
|
||||
run: REF="${GITHUB_REF:-}" COMMIT="${GITHUB_SHA:-HEAD}" IMAGE="${{ steps.prep.outputs.image }}" .gitea/scripts/build-plan.sh
|
||||
|
||||
- name: Build (with or without tests) or Retag, then Push
|
||||
run: |
|
||||
IMAGE="${{ steps.prep.outputs.image }}"
|
||||
TAG_ARGS=""
|
||||
for t in ${{ steps.prep.outputs.tags }}; do TAG_ARGS="${TAG_ARGS} -t ${IMAGE}:${t}"; done
|
||||
echo "🚀 Building container & executing Frontend/Backend checks inside image: ${IMAGE} (tags: ${{ steps.prep.outputs.tags }})"
|
||||
DOCKER_BUILDKIT=1 docker build --progress=plain --build-arg APP_VERSION="${{ steps.prep.outputs.app_version }}" ${TAG_ARGS} .
|
||||
echo "✅ All checks & tests passed inside container! Pushing..."
|
||||
for t in ${{ steps.prep.outputs.tags }}; do docker push "${IMAGE}:${t}"; done
|
||||
TAGS="${{ steps.plan.outputs.tags }}"
|
||||
echo "ℹ️ ${{ steps.plan.outputs.reason }}"
|
||||
if [ "${{ steps.plan.outputs.action }}" = "retag" ]; then
|
||||
SOURCE="${{ steps.plan.outputs.source_image }}"
|
||||
docker pull "${SOURCE}"
|
||||
for t in ${TAGS}; do
|
||||
docker tag "${SOURCE}" "${IMAGE}:${t}"
|
||||
docker push "${IMAGE}:${t}"
|
||||
done
|
||||
else
|
||||
TAG_ARGS=""
|
||||
for t in ${TAGS}; do TAG_ARGS="${TAG_ARGS} -t ${IMAGE}:${t}"; done
|
||||
DOCKER_BUILDKIT=1 docker build --progress=plain \
|
||||
--build-arg APP_VERSION="${{ steps.plan.outputs.app_version }}" \
|
||||
--build-arg SKIP_TESTS="${{ steps.plan.outputs.skip_tests }}" \
|
||||
--build-arg GIT_SHA="${{ steps.plan.outputs.sha }}" \
|
||||
--build-arg GIT_TREE="${{ steps.plan.outputs.tree }}" \
|
||||
${TAG_ARGS} .
|
||||
for t in ${TAGS}; do docker push "${IMAGE}:${t}"; done
|
||||
fi
|
||||
+33
-1
@@ -6,6 +6,37 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.11.0] - 2026-10-30
|
||||
|
||||
### Added
|
||||
- Fokus-Modus: Ordner und Notizen gleiten von links herein – über den Ordner-Knopf oder einen Klick auf den Titel in der Kopfleiste, auf breiten Bildschirmen auch, wenn die Maus den linken Rand berührt. Eine andere Notiz öffnet sich, ohne den Fokus-Modus zu verlassen; `Esc` schließt zuerst die Leiste.
|
||||
- Fokus-Modus für Notizen: `Strg+Umschalt+F` oder „Fokus-Modus starten“ im Drei-Punkte-Menü. Nur die Notiz in angenehmer Lesebreite über dem ganzen Fenster; die Kopfleiste (Titel, Rückgängig, Teilen, Menü, „Beenden“) gleitet herein, wenn die Maus den oberen Rand berührt. `Esc` beendet ihn; am Handy über einen runden Knopf unten rechts.
|
||||
- **Push-Benachrichtigungen** (Web Push) auf Handy und Desktop, auch wenn MindSift geschlossen ist: unter Kontoeinstellungen → Alarme pro Gerät einschalten und testen; „Push“ ist eine eigene Spalte für jedes Ereignis (auch fällige Aufgaben) und die Tageszusammenfassung. iPhone und iPad ab iOS 16.4, wenn MindSift zum Home-Bildschirm hinzugefügt wurde. Schlüssel per `VAPID_*` oder automatisch erzeugt. API: `/push/key`, `/push/devices`, `/push/subscribe`, `/push/unsubscribe`, `/push/test`.
|
||||
- Links auf einzelne Punkte: „Link zu diesem Punkt kopieren“ in der Aktionsblase eines Punkts oder im Link-Dialog (`/link`) über „Punkt dieser Notiz wählen“. Format `[[Notiz#^id|Notiz › Auszug]]`; ein Klick öffnet die Notiz, springt zum Punkt und hebt ihn hervor. Gibt es den Punkt nicht mehr (verschoben, ausgelagert, gelöscht), öffnet sich die Notiz mit einem Hinweis. Wiki-Links stehen im Text jetzt mit ihrem Anzeigetext und sind direkt anklickbar.
|
||||
- Globaler Desktop-App-Footer mit Versionsnummer, „Über MindSift“, dynamischen Rechtstexten (Impressum, Datenschutz) sowie optional konfigurierbarem Custom-Link (`LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL`).
|
||||
- „Über MindSift“-Dialog und Versionsanzeige im mobilen Profil-Menü (Avatar-Menü oben rechts).
|
||||
- PWA Web Share Target: Text, Links und Bilder können über den nativen Teilen-Dialog des Smartphones direkt in den MindSift-Posteingang geteilt werden (`share_target` im Manifest, Abfangen im Service Worker via IndexedDB und Übernahme beim App-Start; Fallback-Route `POST /share-target` im Server).
|
||||
|
||||
### Changed
|
||||
- Inline-Code mit dezentem hellen Schimmer im dunklen Theme (Textfarbe mit geringer Deckkraft als Hintergrund) statt dunklem Code-Hintergrund, in hellen Themes entsprechend leicht grau.
|
||||
- Installierte App: Statusleiste beim Start und Android-Navigationsleiste schwarz statt blau (die Navigationsleiste kann der Browser nur aus dem Manifest färben, nicht pro Theme); die Statusleiste folgt danach wie bisher dem Theme.
|
||||
- Notizen-Seitenleiste: Die ausgewählte Notiz ist nur noch hinterlegt (ohne Rahmen); das Journal-Panel und die Monats-/Jahresauswahl im Kalender kommen ebenfalls ohne Rahmen aus.
|
||||
- Code-Blöcke ohne Rahmen und Kopfleiste: oben links nur die Sprache, Kopieren als runder Symbol-Knopf unten rechts (erscheint beim Überfahren, am Handy immer). „In normalen Text umwandeln“ steht für Code-Blöcke (und nummerierte Punkte) in der Aktionsblase des Punkts.
|
||||
- Code-, Audio- und Diktat-Blöcke haben unabhängig vom Theme runde Ecken; Callouts bleiben eckig.
|
||||
- Inline-Code ist einfarbig (Text in Haupttextfarbe auf dem Code-Hintergrund des Themes) statt in Akzentfarbe, für bessere Lesbarkeit.
|
||||
- Notizen: Enter in einem leeren eingerückten Punkt rückt ihn nur noch aus, wenn danach kein Punkt mehr auf derselben Ebene folgt; sonst entsteht wie gewohnt ein neuer Punkt darunter (auch nummerierte Listen werden dann nicht beendet).
|
||||
- Workspace-Leiste: Live, Geteilt und Speicherstatus ohne Rahmen im gleichen Stil, ein senkrechter Trennstrich vor den Notiz-Knöpfen. Footer ohne Linie darüber.
|
||||
- Journal: Panel und Kalender öffnen sich mit einer sanften Animation, die Journal-Ansicht blendet ein.
|
||||
- Einfügen und Markdown-Import: Eine Liste direkt nach einem Absatz („Folgende Punkte:“) wird unter diesem Absatz eingerückt.
|
||||
- Workspace am Desktop (ab 1024 px): Die eigene Kopfleiste der Notiz entfällt. Rückgängig, Wiederholen, Teilen und das Drei-Punkte-Menü stehen oben in der Workspace-Leiste, in fester Reihenfolge: Avatare, Live, Geteilt, Speicherstatus (nur noch als Symbol, Text als Tooltip), dann die Knöpfe. Beim Fokus-Modus stehen die Breadcrumbs über dem Titel. Am Handy und Tablet bleibt die Kopfleiste.
|
||||
- Notizen-Seitenleiste: Überschrift „Notizen & Outliner“ entfernt, Suche und die Knöpfe für neuen Ordner und neue Notiz stehen in einer Zeile.
|
||||
- Journal: Geöffnet ist es ein hinterlegtes Panel mit Rahmen, „Journal“ ist dessen Überschrift; ein zweiter Klick schließt es und zeigt wieder die vorherige Notiz. Ob das Journal offen war und welche Notiz offen war, bleibt beim Wechsel zwischen den Tabs erhalten (Journal-Tage öffnen sich nicht mehr als einzelne Notiz).
|
||||
- Notizen: Beim Einfügen und beim Markdown-Import werden Absätze und Listen unter einer Überschrift eingerückt (H2 unter H1 eine Ebene tiefer), so lassen sich Abschnitte einklappen und fokussieren. Enter am Ende einer Überschrift legt den neuen Punkt als Unterpunkt an; `- `, `* ` oder `+ ` in einem leeren Punkt machen ihn zum normalen Aufzählungspunkt.
|
||||
- CI: Tests laufen pro Code-Stand nur einmal. `main` baut ohne erneute Tests, wenn derselbe Stand schon als `dev` getestet wurde; ein Release-Tag auf denselben Commit wie `latest` baut nicht neu, sondern taggt `latest` um. Die Zuordnung steht als Label im Image (keine zusätzlichen Image-Tags in der Registry). Dockerfile: `SKIP_TESTS`, BuildKit-Caches für Go und npm.
|
||||
|
||||
### Fixed
|
||||
- Notizen: Formatierung innerhalb von fett, kursiv oder durchgestrichen wird angezeigt, z. B. fetter Inline-Code `**`services`**` (bisher standen Backticks im Text).
|
||||
|
||||
## [0.10.0] - 2026-10-30
|
||||
|
||||
### Added
|
||||
@@ -254,7 +285,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||
|
||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.10.0...dev
|
||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.11.0...dev
|
||||
[0.11.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.10.0...v0.11.0
|
||||
[0.10.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.9.0...v0.10.0
|
||||
[0.9.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.8.0...v0.9.0
|
||||
[0.8.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.7.0...v0.8.0
|
||||
|
||||
+23
-6
@@ -4,11 +4,17 @@
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
RUN --mount=type=cache,target=/root/.npm npm install --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
# Version shown in the header: the git tag for releases (set by CI), otherwise "<package version>-dev"
|
||||
ARG APP_VERSION=""
|
||||
RUN npx tsc --noEmit && npm run lint && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build
|
||||
ARG SKIP_TESTS=0
|
||||
RUN if [ "$SKIP_TESTS" != "1" ]; then \
|
||||
npx tsc --noEmit && npm run lint && npm run test:run; \
|
||||
else \
|
||||
echo "Skipping frontend tests (SKIP_TESTS=1)"; \
|
||||
fi && \
|
||||
APP_VERSION="${APP_VERSION}" npm run build
|
||||
|
||||
# ==============================================================================
|
||||
# Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer)
|
||||
@@ -19,7 +25,8 @@ RUN apk add --no-cache git ca-certificates
|
||||
|
||||
# Cache Go modules & pre-compile heavy dependencies (modernc.org/sqlite) in a dedicated layer
|
||||
COPY go.mod ./
|
||||
RUN go get github.com/go-chi/chi/v5@v5.2.0 \
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
|
||||
go get github.com/go-chi/chi/v5@v5.2.0 \
|
||||
github.com/go-chi/cors@v1.2.1 \
|
||||
github.com/golang-jwt/jwt/v5@v5.2.1 \
|
||||
github.com/gorilla/websocket@v1.5.3 \
|
||||
@@ -38,16 +45,26 @@ COPY cmd/ ./cmd/
|
||||
COPY internal/ ./internal/
|
||||
COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist
|
||||
|
||||
ARG SKIP_TESTS=0
|
||||
# Run go mod tidy, static analysis (go vet), unit tests (go test), and compile final binary
|
||||
RUN go mod tidy && \
|
||||
CGO_ENABLED=0 go vet ./... && \
|
||||
CGO_ENABLED=0 go test -v ./... && \
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
|
||||
go mod tidy && \
|
||||
if [ "$SKIP_TESTS" != "1" ]; then \
|
||||
CGO_ENABLED=0 go vet ./... && \
|
||||
CGO_ENABLED=0 go test -v ./...; \
|
||||
else \
|
||||
echo "Skipping backend tests (SKIP_TESTS=1)"; \
|
||||
fi && \
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/mindsift ./cmd/server
|
||||
|
||||
# ==============================================================================
|
||||
# Stage 3: Minimal Production Runtime (< 35 MB)
|
||||
# ==============================================================================
|
||||
FROM alpine:3.21
|
||||
# The code state the image was built from (CI: decides whether a later build may skip the tests)
|
||||
ARG GIT_SHA=""
|
||||
ARG GIT_TREE=""
|
||||
LABEL org.opencontainers.image.revision="${GIT_SHA}" io.mindsift.tree="${GIT_TREE}"
|
||||
RUN apk add --no-cache ca-certificates tzdata && \
|
||||
mkdir -p /data /tmp && \
|
||||
chmod 1777 /tmp
|
||||
|
||||
+7
-1
@@ -7,8 +7,9 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| GET | `/health` | Lebenszeichen |
|
||||
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`) |
|
||||
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`, `customLinkLabel`, `customLinkUrl`) |
|
||||
| GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist |
|
||||
| POST | `/share-target` | PWA Web Share Target Fallback: Weiterleitung auf `/` (`303 See Other`), falls Service Worker noch inaktiv |
|
||||
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
|
||||
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben; `429` nach 20 Versuchen pro Adresse in 15 Minuten |
|
||||
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
|
||||
@@ -125,6 +126,11 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
|
||||
| GET/POST | `/documents/{id}/shares` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) |
|
||||
| DELETE | `/documents/{id}/shares/{userId}` | Notiz-Freigabe entfernen |
|
||||
| POST | `/documents/{id}/extract` | `{blockId, clientId}`: legt aus den Unterpunkten des Punkts eine eigene Notiz an (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht) → `{document, link}`. Die Ursprungsnotiz ändert der Server nicht; der Editor ersetzt den Ast selbst durch `link` (normale eigene Änderung, Strg+Z möglich). Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt |
|
||||
| GET | `/push/key` | `{enabled, publicKey}` – öffentlicher VAPID-Schlüssel für `pushManager.subscribe` (in der Demo `enabled: false`) |
|
||||
| GET | `/push/devices` | Eigene Geräte mit Push → `{devices: [{id, endpoint, device, createdAt}]}` |
|
||||
| POST | `/push/subscribe` | `{endpoint, keys: {p256dh, auth}, device}` – Gerät anmelden (nur `https`-Endpunkte) |
|
||||
| POST | `/push/unsubscribe` | `{endpoint}` – Gerät abmelden |
|
||||
| POST | `/push/test` | Test-Benachrichtigung an alle eigenen Geräte → `{devices}`; `400` ohne Gerät |
|
||||
| GET | `/journal` | Tage des eigenen Journals, neueste zuerst → `{days: [{date, docId, empty}]}` (`empty`: Notiz noch ohne Text; Tage im Papierkorb fehlen) |
|
||||
| POST | `/journal/{date}` | Notiz des Tages (`JJJJ-MM-TT`) öffnen; gibt es noch keine, wird sie angelegt (Titel z. B. „Freitag, 30. Oktober 2026“, Icon 📅) → Dokument, `201` bei Neuanlage. Die Journal-Notizen sind normale Notizen (Teilen, Suche, Sync) |
|
||||
| POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) |
|
||||
|
||||
@@ -71,6 +71,8 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
||||
| `SMTP_FROM_NAME` | `MindSift` | Anzeigename des Absenders, wenn `SMTP_FROM` nur eine Adresse ist (`From: "MindSift" <adresse>`, der SMTP-Umschlag bleibt die reine Adresse); ein Name in `SMTP_FROM` hat Vorrang |
|
||||
| `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) |
|
||||
| `NOTIFY_DIGEST_MINUTES` | `5` | Benachrichtigungen (Freigaben, beobachtete Aufgaben …) werden gesammelt und höchstens alle so viele Minuten gebündelt verschickt |
|
||||
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | – | Schlüsselpaar für Web-Push; ohne Angabe einmal erzeugt und in `push_keys` gespeichert |
|
||||
| `VAPID_SUBJECT` | `BASE_URL` (wenn https) | Kontakt für die Push-Dienste (`mailto:` oder `https://`) |
|
||||
| `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB` | `15` / `50` | Größtes Bild bzw. größte Audiodatei in Notizen in MB |
|
||||
| `MEDIA_QUOTA_MB` | `0` | Speicherplatz für Medien pro Benutzer in MB (`0` = unbegrenzt) |
|
||||
| `MEDIA_DEMO_MAX_MB` | `3` | Nur im Demo-Modus: maximale Dateigröße für Medien-Uploads in MB |
|
||||
@@ -82,6 +84,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
||||
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
||||
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster |
|
||||
| `LEGAL_IMPRINT_CONTENT_FILE`, `LEGAL_PRIVACY_CONTENT_FILE` | leer | Pfad zu einer Datei im Container mit Markdown-Inhalt für Impressum bzw. Datenschutz. Ohne URL und Text bleibt der Link unsichtbar. |
|
||||
| `LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL` | leer | Eigener Link im globalen Desktop-Footer (nur `https://`, sonst ignoriert) |
|
||||
|
||||
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
|
||||
|
||||
@@ -106,7 +109,7 @@ Bilder und Audio liegen in `$DATA_DIR/media/<docId>/<id>.<ext>`, beschrieben in
|
||||
|
||||
## Benachrichtigungen
|
||||
|
||||
`internal/api/notify.go`, `internal/storage/notifications.go` (Migration 7). Jede Person wählt pro Ereignisart die Kanäle – eigene E-Mail (Alarm-Adresse, sonst Konto-Adresse) eigener Discord-Webhook und eigener Webhook (`customWebhookUrl`, JSON per POST) aus den Alarmen –, gespeichert als JSON in `users.notification_settings` (`GET /auth/me`, `PATCH /auth/settings` → `notificationSettings`). Ereignisse: `due` (Fälligkeits-Alarm, sofort über den Alarm-Ticker), `shared` (Notiz, Ordner, Aufgabenliste oder Liste geteilt; bei Gruppen alle Mitglieder), `taskDone` (Aufgabe einer geteilten Liste erledigt), `watched` (jede Änderung an einer beobachteten Aufgabe, `task_watchers`, `POST/DELETE /tasks/{id}/watch`). Alles außer `due` landet in `notification_queue`; ein Job im Minutentakt schickt je Person eine gebündelte Nachricht, sobald das älteste Ereignis `NOTIFY_DIGEST_MINUTES` (Standard 5) alt ist (E-Mail als Text + HTML, Discord als ein Embed mit höchstens 10 Zeilen, eigener Webhook als JSON `{"event":"digest","heading","items":[{"kind","title","text"}],"link","timestamp"}`; fällige Aufgaben gehen dort als `{"event":"task_due",…}` einzeln raus), nach 3 Fehlversuchen wird verworfen. Eigene Änderungen benachrichtigen nie den Handelnden, außer bei beobachteten Aufgaben mit `includeOwnWatched`. `paused` stoppt alles, auch Alarme. **Tageszusammenfassung:** zur gewählten Uhrzeit (Server-Zeitzone, innerhalb von 30 Minuten, einmal pro Tag) überfällige, heutige und morgige offene Aufgaben samt Anzahl der folgenden 7 Tage; nichts fällig → keine Nachricht. Im Demo-Modus laufen die Jobs nicht.
|
||||
`internal/api/notify.go`, `internal/storage/notifications.go` (Migration 7). Jede Person wählt pro Ereignisart die Kanäle – eigene E-Mail (Alarm-Adresse, sonst Konto-Adresse) eigener Discord-Webhook, eigener Webhook (`customWebhookUrl`, JSON per POST) und Web-Push an die eigenen Geräte (`internal/api/push.go`, Migration 9, Tabelle `push_subscriptions`; Geräte, die der Push-Dienst nicht mehr kennt (404/410), werden entfernt) –, gespeichert als JSON in `users.notification_settings` (`GET /auth/me`, `PATCH /auth/settings` → `notificationSettings`). Ereignisse: `due` (Fälligkeits-Alarm, sofort über den Alarm-Ticker), `shared` (Notiz, Ordner, Aufgabenliste oder Liste geteilt; bei Gruppen alle Mitglieder), `taskDone` (Aufgabe einer geteilten Liste erledigt), `watched` (jede Änderung an einer beobachteten Aufgabe, `task_watchers`, `POST/DELETE /tasks/{id}/watch`). Alles außer `due` landet in `notification_queue`; ein Job im Minutentakt schickt je Person eine gebündelte Nachricht, sobald das älteste Ereignis `NOTIFY_DIGEST_MINUTES` (Standard 5) alt ist (E-Mail als Text + HTML, Discord als ein Embed mit höchstens 10 Zeilen, eigener Webhook als JSON `{"event":"digest","heading","items":[{"kind","title","text"}],"link","timestamp"}`; fällige Aufgaben gehen dort als `{"event":"task_due",…}` einzeln raus), nach 3 Fehlversuchen wird verworfen. Eigene Änderungen benachrichtigen nie den Handelnden, außer bei beobachteten Aufgaben mit `includeOwnWatched`. `paused` stoppt alles, auch Alarme. **Tageszusammenfassung:** zur gewählten Uhrzeit (Server-Zeitzone, innerhalb von 30 Minuten, einmal pro Tag) überfällige, heutige und morgige offene Aufgaben samt Anzahl der folgenden 7 Tage; nichts fällig → keine Nachricht. Im Demo-Modus laufen die Jobs nicht.
|
||||
|
||||
## Link-Vorschau
|
||||
|
||||
|
||||
+3
-1
@@ -14,6 +14,8 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j
|
||||
| `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` |
|
||||
| `notification_queue` | Wartende Benachrichtigungen bis zum gebündelten Versand (Migration 7) | `user_id`, `kind` (`shared`/`taskDone`/`watched`), `payload` (JSON), `created_at`, `attempts` |
|
||||
| `task_watchers` | Beobachtete Aufgaben (Migration 7) | `task_id`, `user_id`, `created_at` |
|
||||
| `push_subscriptions` | Geräte mit Web-Push (Migration 9) | `user_id`, `endpoint` (eindeutig), `p256dh`, `auth`, `device`, `created_at` |
|
||||
| `push_keys` | VAPID-Schlüsselpaar, falls nicht per Umgebung gesetzt (Migration 9) | eine Zeile: `public_key`, `private_key` |
|
||||
| `journal_days` | Journal: welche Notiz zu welchem Tag gehört (Migration 8) | `user_id`, `day` (`JJJJ-MM-TT`), `doc_id`; Primärschlüssel `(user_id, day)` |
|
||||
| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) |
|
||||
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
|
||||
@@ -85,7 +87,7 @@ teams 1─n team_members n─1 users
|
||||
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
||||
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
|
||||
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
||||
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (Migrationen 3 bis 8: 3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“, 8 „Journal“) genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 (`migrateBaseline`) baut das Basisschema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
|
||||
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (Migrationen 3 bis 9: 3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“, 8 „Journal“, 9 „Web-Push“) genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 (`migrateBaseline`) baut das Basisschema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
|
||||
|
||||
## Sicherung
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
||||
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
|
||||
* **Aufgaben beobachten:** Beliebige Aufgaben per Klick abonnieren, um über alle Änderungen informiert zu werden.
|
||||
* **Wiederholungen:** Wiederkehrende Aufgaben (täglich, wöchentlich, monatlich, jährlich) mit automatischer Folgeaufgabe beim Abhaken.
|
||||
* **Tageszusammenfassung:** Tägliche Übersicht fälliger Aufgaben zur selbst gewählten Uhrzeit per E-Mail/Discord.
|
||||
* **Tageszusammenfassung:** Tägliche Übersicht fälliger Aufgaben zur selbst gewählten Uhrzeit per E-Mail, Discord, eigenem Webhook oder Push.
|
||||
* **Push-Benachrichtigungen** auf Handy und Desktop (auch bei geschlossener App; iPhone/iPad als Home-Bildschirm-App), als eigener Kanal je Ereignis.
|
||||
* **Alarme & Benachrichtigungen:** Fälligkeits-Alarme sofort per E-Mail, Discord oder eigenem Webhook; granulare Benachrichtigungen für Freigaben, erledigte geteilte Aufgaben und beobachtete Aufgaben sowie tägliche Zusammenfassungen zur gewählten Uhrzeit.
|
||||
|
||||
<p align="center">
|
||||
@@ -67,7 +68,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
||||
* **Bilder & Audio in Notizen:** `/image` (Datei, Kamera, Zwischenablage oder Link), `/audio` (Sprachnotiz oder Datei-Upload) und `/diktat` (Live-Transkription).
|
||||
* **Link-Vorschau:** Automatische Vorschaukarten über Punkten mit reiner Web-Adresse.
|
||||
* **Ast auslagern & Notizen zusammenführen:** Bulletpoints samt Unterbaum in eigenständige Notizen auslagern (`/extract`) oder Notizen per Drag & Drop bzw. Menü zusammenführen (jeweils mit vollständiger Rückgängig-Funktion).
|
||||
* Wiki-Links `[[Notiz]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen.
|
||||
* Wiki-Links `[[Notiz]]`, auch auf **einzelne Punkte** (`[[Notiz#^id|Text]]`, kopierbar an jedem Punkt), Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen.
|
||||
* Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`.
|
||||
* Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt; `Strg+Z` / `Strg+Umschalt+Z` nimmt eigene Änderungen zurück bzw. stellt sie wieder her.
|
||||
* **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen als **Bearbeiter** oder **Leser** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein.
|
||||
|
||||
@@ -33,6 +33,8 @@ services:
|
||||
- NOTIFY_DIGEST_MINUTES=${NOTIFY_DIGEST_MINUTES:-5}
|
||||
- SMTP_TLS=${SMTP_TLS:-true}
|
||||
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
|
||||
# Web-Push: ohne Angabe erzeugt MindSift die Schlüssel selbst
|
||||
# - VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:admin@example.com}
|
||||
# Impressum / Datenschutz
|
||||
- LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-}
|
||||
- LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-}
|
||||
@@ -40,6 +42,9 @@ services:
|
||||
- LEGAL_PRIVACY_CONTENT=${LEGAL_PRIVACY_CONTENT:-}
|
||||
- LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-}
|
||||
- LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-}
|
||||
# Eigener Link im Footer (nur https://)
|
||||
# - LEGAL_CUSTOM_LINK_LABEL=${LEGAL_CUSTOM_LINK_LABEL:-}
|
||||
# - LEGAL_CUSTOM_LINK_URL=${LEGAL_CUSTOM_LINK_URL:-}
|
||||
# Bilder und Audio in Notizen
|
||||
- MEDIA_MAX_IMAGE_MB=${MEDIA_MAX_IMAGE_MB:-15}
|
||||
- MEDIA_MAX_AUDIO_MB=${MEDIA_MAX_AUDIO_MB:-50}
|
||||
|
||||
+26
-8
@@ -1,18 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds MindSift, starts it in demo mode on port 18080 with a fresh database and runs the browser tests.
|
||||
# Builds MindSift, starts it in demo mode with a fresh database and runs the browser tests.
|
||||
# Requires podman (or docker via ENGINE=docker). Usage: e2e/run.sh
|
||||
# Every run uses its own container, image tag and port, so runs from several working copies at the
|
||||
# same time cannot remove or test each other's server.
|
||||
set -euo pipefail
|
||||
ENGINE="${ENGINE:-podman}"
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
WORK="$(mktemp -d)"
|
||||
trap '"$ENGINE" rm -f mindsift-e2e >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT
|
||||
RUN_ID="$$-$RANDOM"
|
||||
NAME="mindsift-e2e-$RUN_ID"
|
||||
IMAGE="mindsift-e2e-image:$RUN_ID"
|
||||
# A free port above 18000 (E2E_PORT overrides)
|
||||
PORT="${E2E_PORT:-}"
|
||||
if [ -z "$PORT" ]; then
|
||||
for p in $(shuf -i 18100-18999 -n 50); do
|
||||
if ! (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then PORT="$p"; break; fi
|
||||
done
|
||||
fi
|
||||
cleanup() {
|
||||
"$ENGINE" rm -f "$NAME" >/dev/null 2>&1 || true
|
||||
"$ENGINE" rmi "$IMAGE" >/dev/null 2>&1 || true
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Baue Image …"
|
||||
"$ENGINE" build -q -t mindsift-e2e-image "$ROOT" >/dev/null
|
||||
"$ENGINE" run -d --name mindsift-e2e --network host -e PORT=18080 -e MINDA_DEMO=true \
|
||||
-e JWT_SECRET=e2e-only-secret -v "$WORK":/data:Z mindsift-e2e-image >/dev/null
|
||||
for _ in $(seq 1 60); do curl -s -m 1 http://localhost:18080/api/v1/health >/dev/null && break; sleep 0.5; done
|
||||
"$ENGINE" build -q -t "$IMAGE" "$ROOT" >/dev/null
|
||||
"$ENGINE" run -d --name "$NAME" --network host -e PORT="$PORT" -e MINDA_DEMO=true \
|
||||
-e JWT_SECRET=e2e-only-secret -v "$WORK":/data:Z "$IMAGE" >/dev/null
|
||||
for _ in $(seq 1 60); do curl -s -m 1 "http://localhost:$PORT/api/v1/health" >/dev/null && break; sleep 0.5; done
|
||||
|
||||
echo "Starte Browser-Tests …"
|
||||
"$ENGINE" run --rm --network host -v "$ROOT/e2e":/e2e:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \
|
||||
echo "Starte Browser-Tests (Port $PORT) …"
|
||||
"$ENGINE" run --rm --network host -e MINDSIFT_URL="http://localhost:$PORT" -v "$ROOT/e2e":/e2e:Z -w /e2e \
|
||||
mcr.microsoft.com/playwright:v1.48.2-jammy \
|
||||
sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node collab.e2e.mjs && node share.e2e.mjs && node lock.e2e.mjs && node channels.e2e.mjs'
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<!-- Blue while loading (matches the splash screen); the app switches it to the theme background -->
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="MindSift" />
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#2563eb",
|
||||
"theme_color": "#2563eb",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#000000",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
@@ -47,6 +47,22 @@
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/share-target",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "url",
|
||||
"files": [
|
||||
{
|
||||
"name": "media",
|
||||
"accept": ["image/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Gedanke sofort erfassen",
|
||||
|
||||
+105
-1
@@ -5,10 +5,12 @@
|
||||
// - Icons and manifest: from the cache, refreshed in the background.
|
||||
// - API requests are never touched; offline data comes from the app's own cache (lib/offline.ts).
|
||||
// Raise VERSION when this file changes in a way that needs fresh caches.
|
||||
const VERSION = 'v2';
|
||||
const VERSION = 'v3';
|
||||
const SHELL_CACHE = `mindsift-shell-${VERSION}`;
|
||||
const ASSET_CACHE = `mindsift-assets-${VERSION}`;
|
||||
const MAX_ASSETS = 80;
|
||||
const SHARE_DB_NAME = 'mindsift-share-target';
|
||||
const SHARE_STORE_NAME = 'shared_items';
|
||||
const SHELL_URLS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
@@ -80,8 +82,78 @@ async function staleWhileRevalidate(request) {
|
||||
return hit || refresh;
|
||||
}
|
||||
|
||||
function openShareDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(SHARE_DB_NAME, 1);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) {
|
||||
db.createObjectStore(SHARE_STORE_NAME, { autoIncrement: true, keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function savePendingShare(item) {
|
||||
const db = await openShareDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(SHARE_STORE_NAME, 'readwrite');
|
||||
tx.objectStore(SHARE_STORE_NAME).add(item);
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
tx.onerror = () => {
|
||||
db.close();
|
||||
reject(tx.error);
|
||||
};
|
||||
tx.onabort = () => {
|
||||
db.close();
|
||||
reject(tx.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function handleShareTarget(event) {
|
||||
try {
|
||||
const formData = await event.request.formData();
|
||||
const title = formData.get('title');
|
||||
const text = formData.get('text');
|
||||
const url = formData.get('url');
|
||||
const media = formData.get('media');
|
||||
|
||||
let fileName = '';
|
||||
let hasMedia = false;
|
||||
if (media && typeof media === 'object') {
|
||||
fileName = media.name || 'image';
|
||||
hasMedia = true;
|
||||
}
|
||||
|
||||
await savePendingShare({
|
||||
title: typeof title === 'string' ? title.trim() : '',
|
||||
text: typeof text === 'string' ? text.trim() : '',
|
||||
url: typeof url === 'string' ? url.trim() : '',
|
||||
fileName,
|
||||
hasMedia,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[SW] Share-Target Fehler:', err);
|
||||
}
|
||||
return Response.redirect('/?shared=pending', 303);
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
if (request.method === 'POST') {
|
||||
const url = new URL(request.url);
|
||||
if (url.origin === self.location.origin && url.pathname === '/share-target') {
|
||||
event.respondWith(handleShareTarget(event));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (request.method !== 'GET') return;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
|
||||
@@ -94,3 +166,35 @@ self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(staleWhileRevalidate(request));
|
||||
}
|
||||
});
|
||||
|
||||
// Web push: show the notification; a click focuses an open MindSift window or opens a new one
|
||||
self.addEventListener('push', (event) => {
|
||||
let msg = {};
|
||||
try {
|
||||
msg = event.data ? event.data.json() : {};
|
||||
} catch {
|
||||
msg = { body: event.data ? event.data.text() : '' };
|
||||
}
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(msg.title || 'MindSift', {
|
||||
body: msg.body || '',
|
||||
icon: '/icon-192x192.png',
|
||||
badge: '/icon-192x192.png',
|
||||
tag: msg.tag || undefined,
|
||||
data: { url: msg.url || '/' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
const target = new URL((event.notification.data && event.notification.data.url) || '/', self.location.origin);
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windows) => {
|
||||
for (const w of windows) {
|
||||
if (new URL(w.url).origin === target.origin && 'focus' in w) return w.focus();
|
||||
}
|
||||
return self.clients.openWindow(target.href);
|
||||
})
|
||||
);
|
||||
});
|
||||
+71
-2
@@ -65,6 +65,9 @@ import { useMobileActions } from './context/MobileActionContext';
|
||||
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
|
||||
import { UserAvatar } from './components/UserAvatar';
|
||||
import { CommandPalette } from './components/CommandPalette';
|
||||
import { AppFooter } from './components/AppFooter';
|
||||
import { AboutModal } from './components/AboutModal';
|
||||
import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
|
||||
import { APP_VERSION } from './version';
|
||||
|
||||
type ActiveTab =
|
||||
@@ -170,6 +173,7 @@ export const App: React.FC = () => {
|
||||
|
||||
// Profile Dropdown State
|
||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Mobile Navigation & Quick-Action State
|
||||
@@ -552,6 +556,46 @@ export const App: React.FC = () => {
|
||||
);
|
||||
}, [authStatus.demoMode, authStatus.demoResetInSeconds]);
|
||||
|
||||
// Web Share Target: import shared thoughts from external apps
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const hasPendingShare = params.get('shared') === 'pending';
|
||||
|
||||
const processShares = async () => {
|
||||
try {
|
||||
const shares = await consumePendingShares();
|
||||
if (shares.length === 0) return;
|
||||
|
||||
let importedCount = 0;
|
||||
for (const share of shares) {
|
||||
const content = formatSharedThought(share);
|
||||
if (content) {
|
||||
await api.captureThought(content, 'normal', 'manual', '', { target: 'inbox' });
|
||||
importedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (importedCount > 0) {
|
||||
triggerHaptic(15);
|
||||
setDemoNotice('Erfolgreich im Quick Dump abgelegt');
|
||||
setTimeout(() => setDemoNotice(null), 4000);
|
||||
refreshAll();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Import geteilter Daten:', err);
|
||||
} finally {
|
||||
if (hasPendingShare) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('shared');
|
||||
window.history.replaceState({}, '', url.pathname + (url.search ? url.search : '') + url.hash);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
processShares();
|
||||
}, [user, refreshAll]);
|
||||
|
||||
// The demo database was reset: drop cached answers of the old data, remount the views (they load
|
||||
// fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs).
|
||||
const handleDemoReset = useCallback(() => {
|
||||
@@ -1119,8 +1163,8 @@ export const App: React.FC = () => {
|
||||
{/* Main Container */}
|
||||
{/* The workspace fills the window exactly and scrolls only inside the note, never the page */}
|
||||
<main
|
||||
className={`flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6 ${
|
||||
activeTab === 'workspace' ? 'min-h-0 flex flex-col' : ''
|
||||
className={`flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 ${
|
||||
activeTab === 'workspace' ? 'min-h-0 flex flex-col sm:pb-0' : 'sm:pb-6'
|
||||
}`}
|
||||
>
|
||||
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
||||
@@ -1202,6 +1246,11 @@ export const App: React.FC = () => {
|
||||
</ViewErrorBoundary>
|
||||
</main>
|
||||
|
||||
<AppFooter
|
||||
legal={authStatus.legal}
|
||||
onOpenAbout={() => setShowAbout(true)}
|
||||
/>
|
||||
|
||||
{/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */}
|
||||
<nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
|
||||
{/* Col 1: Triage */}
|
||||
@@ -1594,6 +1643,19 @@ export const App: React.FC = () => {
|
||||
<span>Hilfe & Anleitung</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowMobileProfile(false);
|
||||
setShowAbout(true);
|
||||
}}
|
||||
className="w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer text-main"
|
||||
>
|
||||
<Brain className="w-4 h-4 text-accent" />
|
||||
<span>Über MindSift</span>
|
||||
</button>
|
||||
|
||||
{isServerAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1626,6 +1688,11 @@ export const App: React.FC = () => {
|
||||
<span>Abmelden</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Version & About Hint on Mobile */}
|
||||
<div className="pt-2 text-center text-[10px] text-faint font-mono">
|
||||
MindSift {APP_VERSION}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1725,6 +1792,8 @@ export const App: React.FC = () => {
|
||||
onOpenTask={handleOpenTask}
|
||||
onOpenList={handleOpenList}
|
||||
/>
|
||||
|
||||
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { AuthStatus } from '../lib/types';
|
||||
import { APP_VERSION } from '../version';
|
||||
import { LegalLinks } from './LegalLinks';
|
||||
|
||||
interface AppFooterProps {
|
||||
legal?: AuthStatus['legal'];
|
||||
onOpenAbout: () => void;
|
||||
}
|
||||
|
||||
export const AppFooter: React.FC<AppFooterProps> = ({ legal, onOpenAbout }) => {
|
||||
return (
|
||||
<footer className="hidden sm:flex shrink-0 w-full max-w-6xl mx-auto px-4 py-2 items-center justify-between text-[11px] text-faint select-none">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenAbout}
|
||||
className="font-semibold text-muted hover:text-main transition-colors cursor-pointer"
|
||||
>
|
||||
MindSift
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenAbout}
|
||||
className="font-mono hover:text-main transition-colors cursor-pointer"
|
||||
title="Über MindSift & Versionsdetails"
|
||||
>
|
||||
{APP_VERSION}
|
||||
</button>
|
||||
<span className="text-border">·</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenAbout}
|
||||
className="hover:text-main transition-colors cursor-pointer underline-offset-2 hover:underline"
|
||||
>
|
||||
Über MindSift
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<LegalLinks legal={legal} className="!text-faint gap-3" />
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
@@ -28,7 +28,9 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
|
||||
}))
|
||||
.filter((e) => e.url || e.hasContent);
|
||||
|
||||
if (entries.length === 0 && !children) return null;
|
||||
const customHref = legal?.customLinkUrl && safeHref(legal.customLinkUrl);
|
||||
|
||||
if (entries.length === 0 && !children && !customHref) return null;
|
||||
|
||||
const openText = async (type: LegalType) => {
|
||||
setError(null);
|
||||
@@ -52,6 +54,16 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{customHref && (
|
||||
<a
|
||||
href={customHref as string}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-main underline-offset-2 hover:underline"
|
||||
>
|
||||
{legal?.customLinkLabel || legal?.customLinkUrl}
|
||||
</a>
|
||||
)}
|
||||
{children}
|
||||
{error && <span className="text-status-err">{error}</span>}
|
||||
{open && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
User as UserIcon,
|
||||
Bell,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Pencil,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
import { AuthStatus, NotificationEvents, NotificationSettings, User, UserTheme } from '../../lib/types';
|
||||
import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, User, UserTheme } from '../../lib/types';
|
||||
import { ApiKeysPanel } from './ApiKeysPanel';
|
||||
import { ServerBackupsCard } from './ServerBackupsCard';
|
||||
import { saveBlob } from '../../lib/download';
|
||||
@@ -33,6 +33,7 @@ import { SessionsPanel } from './SessionsPanel';
|
||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
|
||||
import { isJournalEnabled, setJournalEnabled } from '../../lib/journal';
|
||||
import { currentPushSubscription, disablePush, enablePush, pushSupported } from '../../lib/push';
|
||||
import { FormSheet } from '../../components/FormSheet';
|
||||
|
||||
interface UserSettingsViewProps {
|
||||
@@ -62,21 +63,25 @@ export function buildNotificationSettings(user: User): NotificationSettings {
|
||||
email: s?.events?.due?.email ?? dueEmailDefault,
|
||||
discord: s?.events?.due?.discord ?? dueDiscordDefault,
|
||||
webhook: s?.events?.due?.webhook ?? dueWebhookDefault,
|
||||
push: s?.events?.due?.push ?? false,
|
||||
},
|
||||
shared: {
|
||||
email: s?.events?.shared?.email ?? true,
|
||||
discord: s?.events?.shared?.discord ?? false,
|
||||
webhook: s?.events?.shared?.webhook ?? false,
|
||||
push: s?.events?.shared?.push ?? false,
|
||||
},
|
||||
taskDone: {
|
||||
email: s?.events?.taskDone?.email ?? false,
|
||||
discord: s?.events?.taskDone?.discord ?? false,
|
||||
webhook: s?.events?.taskDone?.webhook ?? false,
|
||||
push: s?.events?.taskDone?.push ?? false,
|
||||
},
|
||||
watched: {
|
||||
email: s?.events?.watched?.email ?? true,
|
||||
discord: s?.events?.watched?.discord ?? true,
|
||||
webhook: s?.events?.watched?.webhook ?? false,
|
||||
push: s?.events?.watched?.push ?? false,
|
||||
},
|
||||
},
|
||||
dailySummary: {
|
||||
@@ -85,6 +90,7 @@ export function buildNotificationSettings(user: User): NotificationSettings {
|
||||
email: s?.dailySummary?.email ?? true,
|
||||
discord: s?.dailySummary?.discord ?? false,
|
||||
webhook: s?.dailySummary?.webhook ?? false,
|
||||
push: s?.dailySummary?.push ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -271,6 +277,46 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
const [savingAlarms, setSavingAlarms] = useState(false);
|
||||
const [testingChannel, setTestingChannel] = useState<string | null>(null);
|
||||
|
||||
// Web push: devices of this account and whether this browser is one of them
|
||||
const [pushDevices, setPushDevices] = useState<PushDevice[]>([]);
|
||||
const [pushHere, setPushHere] = useState(false);
|
||||
const [pushBusy, setPushBusy] = useState(false);
|
||||
const [pushMsg, setPushMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const reloadPush = useCallback(async () => {
|
||||
const [devices, sub] = await Promise.all([api.getPushDevices().catch(() => []), currentPushSubscription()]);
|
||||
setPushDevices(devices);
|
||||
setPushHere(Boolean(sub && devices.some((d) => d.endpoint === sub.endpoint)));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void reloadPush();
|
||||
}, [reloadPush]);
|
||||
const togglePushHere = async (on: boolean) => {
|
||||
setPushBusy(true);
|
||||
setPushMsg(null);
|
||||
try {
|
||||
if (on) await enablePush();
|
||||
else await disablePush();
|
||||
await reloadPush();
|
||||
setPushMsg({ type: 'ok', text: on ? 'Push ist auf diesem Gerät eingeschaltet.' : 'Push ist auf diesem Gerät ausgeschaltet.' });
|
||||
} catch (err) {
|
||||
setPushMsg({ type: 'err', text: err instanceof Error ? err.message : 'Push konnte nicht eingeschaltet werden' });
|
||||
} finally {
|
||||
setPushBusy(false);
|
||||
}
|
||||
};
|
||||
const sendTestPush = async () => {
|
||||
setPushBusy(true);
|
||||
setPushMsg(null);
|
||||
try {
|
||||
const res = await api.testPush();
|
||||
setPushMsg({ type: 'ok', text: `Test-Benachrichtigung an ${res.devices} ${res.devices === 1 ? 'Gerät' : 'Geräte'} gesendet.` });
|
||||
} catch (err) {
|
||||
setPushMsg({ type: 'err', text: err instanceof Error ? err.message : 'Test fehlgeschlagen' });
|
||||
} finally {
|
||||
setPushBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(currentUser.displayName || '');
|
||||
setEmail(currentUser.email || '');
|
||||
@@ -283,7 +329,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
}, [currentUser]);
|
||||
|
||||
|
||||
const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord' | 'webhook', value: boolean) => {
|
||||
const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord' | 'webhook' | 'push', value: boolean) => {
|
||||
setNotificationSettings((prev) => ({
|
||||
...prev,
|
||||
events: {
|
||||
@@ -1140,23 +1186,28 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
{(() => {
|
||||
const hasDiscordWebhook = Boolean(discordWebhookUrl.trim());
|
||||
const hasCustomWebhook = Boolean(customWebhookUrl.trim());
|
||||
const hasPush = pushDevices.length > 0;
|
||||
return (
|
||||
<>
|
||||
<div className="p-4 bg-canvas border border-border space-y-3">
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-2 sm:gap-4 pb-2 border-b border-border">
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-1 sm:gap-4 pb-2 border-b border-border">
|
||||
<span className="font-bold text-main">Ereignis</span>
|
||||
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
|
||||
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
|
||||
<Mail className="w-3.5 h-3.5 text-accent" />
|
||||
<span>E-Mail</span>
|
||||
</span>
|
||||
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
|
||||
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
|
||||
<MessageSquare className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Discord</span>
|
||||
</span>
|
||||
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
|
||||
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
|
||||
<Globe className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Webhook</span>
|
||||
</span>
|
||||
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
|
||||
<Bell className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Push</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
@@ -1165,17 +1216,17 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={row.key}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-2 sm:gap-4 py-2.5"
|
||||
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-1 sm:gap-4 py-2.5"
|
||||
>
|
||||
<span className="font-medium text-main">{row.label}</span>
|
||||
<div className="w-16 flex justify-center">
|
||||
<div className="w-12 sm:w-16 flex justify-center">
|
||||
<ToggleSwitch
|
||||
checked={routing.email}
|
||||
onChange={(v) => setEventChannel(row.key, 'email', v)}
|
||||
title={`${row.label} – E-Mail`}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-16 flex justify-center">
|
||||
<div className="w-12 sm:w-16 flex justify-center">
|
||||
<ToggleSwitch
|
||||
checked={routing.discord}
|
||||
disabled={!hasDiscordWebhook}
|
||||
@@ -1187,7 +1238,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-16 flex justify-center">
|
||||
<div className="w-12 sm:w-16 flex justify-center">
|
||||
<ToggleSwitch
|
||||
checked={routing.webhook}
|
||||
disabled={!hasCustomWebhook}
|
||||
@@ -1199,6 +1250,14 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-12 sm:w-16 flex justify-center">
|
||||
<ToggleSwitch
|
||||
checked={routing.push}
|
||||
disabled={!hasPush}
|
||||
onChange={(v) => setEventChannel(row.key, 'push', v)}
|
||||
title={hasPush ? `${row.label} – Push` : 'Bitte zuerst unten Push auf einem Gerät einschalten'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1307,6 +1366,20 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted">Push</span>
|
||||
<ToggleSwitch
|
||||
checked={notificationSettings.dailySummary.push}
|
||||
disabled={!hasPush}
|
||||
onChange={(v) =>
|
||||
setNotificationSettings((prev) => ({
|
||||
...prev,
|
||||
dailySummary: { ...prev.dailySummary, push: v },
|
||||
}))
|
||||
}
|
||||
title={hasPush ? 'Tageszusammenfassung – Push' : 'Bitte zuerst unten Push auf einem Gerät einschalten'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1458,6 +1531,49 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Web push: this browser or installed app */}
|
||||
<div className={`p-4 border transition-colors space-y-3 bg-canvas ${pushHere ? 'border-accent' : 'border-border'}`}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<Bell className="w-4 h-4 text-accent shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="font-bold text-main block">Ziel: Push-Benachrichtigungen</span>
|
||||
<span className="text-[11px] text-muted">
|
||||
Systembenachrichtigung auf diesem Gerät, auch wenn MindSift geschlossen ist. Auf iPhone und
|
||||
iPad nur, wenn MindSift zum Home-Bildschirm hinzugefügt wurde.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs text-muted">Dieses Gerät</span>
|
||||
<ToggleSwitch
|
||||
checked={pushHere}
|
||||
disabled={pushBusy || demoMode || !pushSupported()}
|
||||
onChange={(v) => void togglePushHere(v)}
|
||||
title={pushSupported() ? 'Push auf diesem Gerät' : 'Dieser Browser unterstützt keine Push-Benachrichtigungen'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void sendTestPush()}
|
||||
disabled={pushBusy || demoMode || pushDevices.length === 0}
|
||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send className="w-3 h-3 text-accent" />
|
||||
<span>Test senden</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{pushDevices.length > 0 && (
|
||||
<p className="pl-6 text-[11px] text-faint">
|
||||
Aktiv auf {pushDevices.length} {pushDevices.length === 1 ? 'Gerät' : 'Geräten'}:{' '}
|
||||
{pushDevices.map((d) => d.device || 'Gerät').join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{pushMsg && (
|
||||
<p className={`pl-6 text-[11px] ${pushMsg.type === 'ok' ? 'text-status-ok' : 'text-status-err'}`}>{pushMsg.text}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-border flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock('../../../lib/api', async () => {
|
||||
...actual.api,
|
||||
getMe: vi.fn(async () => ({ isLastAdmin: false })),
|
||||
getAuthStatus: vi.fn(async () => ({ demoMode: false })),
|
||||
getPushDevices: vi.fn(async () => []),
|
||||
updateSettings: vi.fn(async (patch) => ({ ...baseUser, ...patch })),
|
||||
},
|
||||
};
|
||||
@@ -88,10 +89,10 @@ describe('UserSettingsView notification matrix', () => {
|
||||
paused: true,
|
||||
includeOwnWatched: true,
|
||||
events: {
|
||||
due: { email: true, discord: true, webhook: false },
|
||||
shared: { email: true, discord: false, webhook: false },
|
||||
taskDone: { email: true, discord: false, webhook: false },
|
||||
watched: { email: true, discord: false, webhook: false },
|
||||
due: { email: true, discord: true, webhook: false, push: false },
|
||||
shared: { email: true, discord: false, webhook: false, push: false },
|
||||
taskDone: { email: true, discord: false, webhook: false, push: false },
|
||||
watched: { email: true, discord: false, webhook: false, push: false },
|
||||
},
|
||||
dailySummary: {
|
||||
enabled: true,
|
||||
@@ -99,6 +100,7 @@ describe('UserSettingsView notification matrix', () => {
|
||||
email: true,
|
||||
discord: true,
|
||||
webhook: false,
|
||||
push: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -87,7 +87,7 @@ export const AudioBlock: React.FC<AudioBlockProps> = ({ content, canEdit, onCapt
|
||||
return (
|
||||
<div className="my-1 space-y-1">
|
||||
{failed ? (
|
||||
<div className="flex items-center gap-2 h-12 px-3 rounded-card border border-border bg-panel text-xs text-muted">
|
||||
<div className="flex items-center gap-2 h-12 px-4 rounded-full border border-border bg-panel text-xs text-muted">
|
||||
<FileAudio className="w-4 h-4" /> Aufnahme nicht verfügbar (offline oder gelöscht)
|
||||
</div>
|
||||
) : src ? (
|
||||
@@ -99,7 +99,7 @@ export const AudioBlock: React.FC<AudioBlockProps> = ({ content, canEdit, onCapt
|
||||
onClick={() => void transcribe()}
|
||||
disabled={transcribing}
|
||||
title="In Text umwandeln (eigene Spracherkennung)"
|
||||
className="btn-secondary h-11 sm:h-8 px-2.5 text-xs flex items-center gap-1.5 shrink-0 disabled:opacity-40"
|
||||
className="btn-secondary !rounded-full h-11 sm:h-8 px-3 text-xs flex items-center gap-1.5 shrink-0 disabled:opacity-40"
|
||||
>
|
||||
{transcribing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Wand2 className="w-3.5 h-3.5" />}
|
||||
<span className="hidden sm:inline">{transcribing ? 'Wird erkannt…' : 'Transkribieren'}</span>
|
||||
@@ -107,7 +107,7 @@ export const AudioBlock: React.FC<AudioBlockProps> = ({ content, canEdit, onCapt
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center h-12 px-3 rounded-card border border-border bg-panel w-64">
|
||||
<div className="flex items-center h-12 px-4 rounded-full border border-border bg-panel w-64">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -108,7 +108,7 @@ export const AudioRecorderPicker: React.FC<AudioRecorderPickerProps> = ({ docId,
|
||||
cancel();
|
||||
}
|
||||
}}
|
||||
className="my-1 inline-flex max-w-full flex-col gap-2 rounded-card border border-border bg-panel-elevated px-3 py-2 focus:outline-none"
|
||||
className="my-1 inline-flex max-w-full flex-col gap-2 rounded-3xl border border-border bg-panel-elevated px-4 py-2 focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{state === 'recording' && (
|
||||
@@ -162,7 +162,7 @@ export const AudioRecorderPicker: React.FC<AudioRecorderPickerProps> = ({ docId,
|
||||
onClick={cancel}
|
||||
disabled={state === 'uploading'}
|
||||
title="Abbrechen (Esc)"
|
||||
className="w-11 h-11 sm:w-7 sm:h-7 flex items-center justify-center rounded-btn text-muted hover:text-main hover:bg-panel shrink-0 disabled:opacity-40"
|
||||
className="w-11 h-11 sm:w-7 sm:h-7 flex items-center justify-center rounded-full text-muted hover:text-main hover:bg-panel shrink-0 disabled:opacity-40"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import Prism from 'prismjs';
|
||||
import { Code, Copy, Check } from 'lucide-react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { DocumentBlock } from '../../lib/types';
|
||||
|
||||
Prism.manual = true;
|
||||
@@ -311,6 +311,7 @@ export interface CodeBlockEditorProps {
|
||||
registerTextareaRef?: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
||||
onChangeContent?: (content: string) => void;
|
||||
onChangeLang?: (lang: string) => void;
|
||||
/** Kept for callers; converting back to text lives in the bullet's action bubble now */
|
||||
onConvertToText?: () => void;
|
||||
handleFocus?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
|
||||
handleBlur?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
|
||||
@@ -326,7 +327,6 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
|
||||
registerTextareaRef,
|
||||
onChangeContent,
|
||||
onChangeLang,
|
||||
onConvertToText,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
handleContentChange,
|
||||
@@ -542,73 +542,50 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
|
||||
const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue));
|
||||
|
||||
return (
|
||||
<div className="bg-code border border-border rounded-btn my-0.5 max-w-full overflow-hidden">
|
||||
{/* Code Block Header */}
|
||||
<div className="flex items-center justify-between gap-2 px-2 py-1 border-b border-border text-[11px] font-mono text-faint">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Code className="w-3.5 h-3.5 text-muted shrink-0" />
|
||||
{canEdit ? (
|
||||
<select
|
||||
aria-label="Sprache auswählen"
|
||||
value={selectValue}
|
||||
onChange={(e) => onChangeLang?.(e.target.value)}
|
||||
className="min-h-[44px] sm:min-h-0 bg-transparent text-muted hover:text-main focus:text-main font-mono text-[11px] font-medium border border-transparent hover:border-border focus:border-accent rounded-btn px-1.5 py-0.5 outline-none cursor-pointer"
|
||||
>
|
||||
<option value="" className="bg-panel text-main">
|
||||
Text
|
||||
</option>
|
||||
{hasCustomUnknownLang && (
|
||||
<option value={selectValue} className="bg-panel text-main">
|
||||
{selectValue}
|
||||
</option>
|
||||
)}
|
||||
{CODE_LANGUAGE_GROUPS.map((group) => (
|
||||
<optgroup key={group.label} label={group.label} className="bg-panel text-muted">
|
||||
{group.options.map((opt) => (
|
||||
<option key={opt.id} value={opt.id} className="bg-panel text-main">
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="font-mono text-[11px] text-muted px-1">{block.lang || 'Text'}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="min-h-[44px] sm:min-h-0 px-2 py-0.5 rounded-btn text-[11px] font-mono text-muted hover:text-main hover:bg-panel-elevated transition-colors inline-flex items-center gap-1 cursor-pointer"
|
||||
title="In die Zwischenablage kopieren"
|
||||
<div className="group/code relative bg-code rounded-2xl my-0.5 max-w-full overflow-hidden">
|
||||
{/* Language at the top left; no header bar */}
|
||||
<div className="flex items-center px-1.5 pt-1 text-[11px] font-mono text-faint">
|
||||
{canEdit ? (
|
||||
<select
|
||||
aria-label="Sprache auswählen"
|
||||
value={selectValue}
|
||||
onChange={(e) => onChangeLang?.(e.target.value)}
|
||||
className="min-h-[44px] sm:min-h-0 bg-transparent text-faint hover:text-main focus:text-main font-mono text-[11px] font-medium border border-transparent hover:border-border focus:border-accent rounded-btn px-1 py-0.5 outline-none cursor-pointer"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-status-ok" />
|
||||
<span className="text-status-ok">Kopiert!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3 opacity-75" />
|
||||
<span>Kopieren</span>
|
||||
</>
|
||||
<option value="" className="bg-panel text-main">
|
||||
Text
|
||||
</option>
|
||||
{hasCustomUnknownLang && (
|
||||
<option value={selectValue} className="bg-panel text-main">
|
||||
{selectValue}
|
||||
</option>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{canEdit && onConvertToText && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConvertToText}
|
||||
className="min-h-[44px] sm:min-h-0 px-2 py-0.5 rounded-btn text-[11px] font-mono text-muted hover:text-accent hover:bg-panel-elevated transition-colors inline-flex items-center cursor-pointer"
|
||||
>
|
||||
Als Text
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{CODE_LANGUAGE_GROUPS.map((group) => (
|
||||
<optgroup key={group.label} label={group.label} className="bg-panel text-muted">
|
||||
{group.options.map((opt) => (
|
||||
<option key={opt.id} value={opt.id} className="bg-panel text-main">
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="font-mono text-[11px] text-faint px-1">{block.lang || 'Text'}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Copy: round icon button at the bottom right, shown on hover (always on touch screens) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="absolute bottom-2 right-2 z-10 h-9 w-9 sm:h-8 sm:w-8 rounded-full bg-panel border border-border shadow-panel flex items-center justify-center text-muted hover:text-main opacity-100 sm:opacity-0 sm:group-hover/code:opacity-100 focus-visible:opacity-100 transition-opacity cursor-pointer"
|
||||
title={copied ? 'Kopiert!' : 'In die Zwischenablage kopieren'}
|
||||
aria-label={copied ? 'Kopiert!' : 'Kopieren'}
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-status-ok" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
|
||||
{/* Live Highlighted Overlay + Textarea with Line Numbers */}
|
||||
<div className="flex relative w-full overflow-hidden">
|
||||
{/* Line Numbers Column */}
|
||||
@@ -618,7 +595,7 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
|
||||
onScroll={handleLineNumbersScroll}
|
||||
onWheel={handleLineNumbersWheel}
|
||||
data-testid="code-line-numbers"
|
||||
className="select-none py-2 pl-2.5 pr-2 font-mono text-xs text-faint leading-relaxed text-right border-r border-border/40 shrink-0 overflow-hidden"
|
||||
className="select-none py-2 pl-2.5 pr-2 font-mono text-xs text-faint leading-relaxed text-right shrink-0 overflow-hidden"
|
||||
>
|
||||
{Array.from({ length: lineCount }, (_, i) => (
|
||||
<div key={i + 1} data-line-number={i + 1}>
|
||||
|
||||
@@ -85,7 +85,7 @@ export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel
|
||||
}}
|
||||
disabled={transcribing || !!error}
|
||||
aria-label="Diktat beenden"
|
||||
className="relative w-full min-h-[56px] sm:min-h-[44px] my-1 px-3 py-2 rounded-card border border-accent bg-accent-dim overflow-hidden flex flex-col gap-1.5 text-left cursor-pointer disabled:cursor-default focus:outline-none"
|
||||
className="relative w-full min-h-[56px] sm:min-h-[44px] my-1 px-4 py-2 rounded-3xl border border-accent bg-accent-dim overflow-hidden flex flex-col gap-1.5 text-left cursor-pointer disabled:cursor-default focus:outline-none"
|
||||
>
|
||||
{(interim || chunkBusy) && !error && (
|
||||
<div className="w-full text-sm text-main whitespace-pre-wrap break-words leading-relaxed pointer-events-none">
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo, useSyncExternalStore } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { buildBlockLink } from '../../lib/wikiLink';
|
||||
import {
|
||||
Pin,
|
||||
Users,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
Download,
|
||||
Maximize2,
|
||||
FileText,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
@@ -50,7 +53,7 @@ export interface DocumentEditorProps {
|
||||
onRestoreDoc?: (docId: string) => void;
|
||||
onOpenShareModal: (doc: Document) => void;
|
||||
onSelectTag: (tag: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
|
||||
onOpenDocBlock?: (docId: string, blockId?: string) => void;
|
||||
onBackMobile?: () => void;
|
||||
onOpenTask?: (taskId: string) => void;
|
||||
@@ -63,6 +66,23 @@ export interface DocumentEditorProps {
|
||||
onDocRemoved?: (docId: string) => void;
|
||||
/** 'journal': one day inside the journal list – no own scrolling, header or backlinks, fixed title */
|
||||
variant?: 'page' | 'journal';
|
||||
/** Large screens: the action buttons render into this element (the workspace tab bar) */
|
||||
actionsTarget?: HTMLElement | null;
|
||||
/** Distraction-free mode: reading width, menu entry to leave it */
|
||||
focusMode?: boolean;
|
||||
onToggleFocusMode?: () => void;
|
||||
}
|
||||
|
||||
// Large screens (lg) put the note's actions into the workspace tab bar and drop the editor header
|
||||
const LARGE_QUERY = '(min-width: 1024px)';
|
||||
function subscribeLarge(onChange: () => void) {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
|
||||
const mq = window.matchMedia(LARGE_QUERY);
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}
|
||||
function isLargeScreen() {
|
||||
return typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(LARGE_QUERY).matches;
|
||||
}
|
||||
|
||||
import { EmojiPickerModal } from './EmojiPickerModal';
|
||||
@@ -214,8 +234,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onDocCreated,
|
||||
onDocRemoved,
|
||||
variant = 'page',
|
||||
actionsTarget,
|
||||
focusMode = false,
|
||||
onToggleFocusMode,
|
||||
}) => {
|
||||
const inJournal = variant === 'journal';
|
||||
const large = useSyncExternalStore(subscribeLarge, isLargeScreen, () => false);
|
||||
const desktopBar = !inJournal && large && Boolean(actionsTarget);
|
||||
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
||||
const [title, setTitle] = useState<string>(initialDoc?.title || '');
|
||||
const [blocks, setBlocks] = useState<DocumentBlock[]>(() =>
|
||||
@@ -477,6 +502,34 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
}, actions.length ? 8000 : 3500);
|
||||
}, []);
|
||||
|
||||
// Link to a single bullet: [[Titel#^id|Titel › Auszug]] into the clipboard
|
||||
const handleCopyBlockLink = useCallback(
|
||||
(blockId: string) => {
|
||||
const block = blocks.find((b) => b.id === blockId);
|
||||
const link = buildBlockLink(title || doc?.title || 'Notiz', blockId, block?.content || '');
|
||||
const done = () => showNotice('Link zum Punkt kopiert – mit Strg+V in eine Notiz einfügen');
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(link).then(done, () => showNotice(`Kopieren nicht möglich: ${link}`));
|
||||
} else {
|
||||
showNotice(`Kopieren nicht möglich: ${link}`);
|
||||
}
|
||||
},
|
||||
[blocks, title, doc?.title, showNotice]
|
||||
);
|
||||
|
||||
// A link pointed to a bullet that is gone (moved, extracted, deleted): the note opens with a hint
|
||||
const blocksForTargetRef = useRef(blocks);
|
||||
blocksForTargetRef.current = blocks;
|
||||
useEffect(() => {
|
||||
if (!initialFocusedBlockId || !doc?.id) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
if (!blocksForTargetRef.current.some((b) => b.id === initialFocusedBlockId)) {
|
||||
showNotice('Der verlinkte Punkt ist in dieser Notiz nicht mehr vorhanden');
|
||||
}
|
||||
}, 1500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [initialFocusedBlockId, doc?.id, showNotice]);
|
||||
|
||||
// The last extraction, so undoing it (toast or Strg+Z) also puts the new note into the trash
|
||||
const lastExtractRef = useRef<{ newDocId: string; blockId: string; link: string } | null>(null);
|
||||
const finishUndoExtract = useCallback(() => {
|
||||
@@ -787,14 +840,209 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const breadcrumbNav = (
|
||||
<nav
|
||||
aria-label="Breadcrumb Navigation"
|
||||
className="flex items-center flex-wrap gap-1 text-xs text-muted"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomedBlockId(null)}
|
||||
className="hover:text-accent inline-flex items-center gap-1 font-medium transition-colors cursor-pointer"
|
||||
title="Zurück zum Hauptdokument"
|
||||
>
|
||||
<Home className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||
<span className="truncate max-w-[130px]">
|
||||
{title || doc?.title || 'Dokument'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{breadcrumbs.slice(1).map((crumb, idx) => {
|
||||
const isCurrent = idx === breadcrumbs.length - 2;
|
||||
return (
|
||||
<React.Fragment key={crumb.id || idx}>
|
||||
<ChevronRight className="w-3 h-3 text-faint shrink-0" />
|
||||
{isCurrent ? (
|
||||
<span
|
||||
className="font-bold text-accent truncate max-w-[160px]"
|
||||
title={crumb.title}
|
||||
>
|
||||
{crumb.title || 'Punkt'}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomedBlockId(crumb.id)}
|
||||
className="hover:text-main truncate max-w-[120px] transition-colors cursor-pointer"
|
||||
title={`Zoom zu "${crumb.title}"`}
|
||||
>
|
||||
{crumb.title || 'Punkt'}
|
||||
</button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
|
||||
// Undo/redo, share and the menu; on large screens they sit in the workspace tab bar instead
|
||||
const actionsBlock = (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{!inTrash && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUndo}
|
||||
disabled={!canEdit || !engineState?.canUndo}
|
||||
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Rückgängig (Strg+Z)"
|
||||
aria-label="Rückgängig (Strg+Z)"
|
||||
>
|
||||
<Undo2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRedo}
|
||||
disabled={!canEdit || !engineState?.canRedo}
|
||||
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Wiederholen (Strg+Y)"
|
||||
aria-label="Wiederholen (Strg+Y)"
|
||||
>
|
||||
<Redo2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{doc && !inTrash && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenShareModal(doc)}
|
||||
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
|
||||
title="Notiz teilen und Berechtigungen"
|
||||
aria-label="Notiz teilen und Berechtigungen"
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="relative" ref={actionsMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowActionsMenu((prev) => !prev)}
|
||||
aria-expanded={showActionsMenu}
|
||||
aria-haspopup="menu"
|
||||
aria-label="Weitere Aktionen"
|
||||
title="Weitere Aktionen"
|
||||
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
|
||||
>
|
||||
<MoreHorizontal className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{showActionsMenu && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full mt-1.5 w-60 rounded-card bg-panel border border-border shadow-lg p-1 z-30 text-xs"
|
||||
>
|
||||
{!inTrash && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShowActionsMenu(false);
|
||||
void handleTogglePin();
|
||||
}}
|
||||
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<Pin
|
||||
className={`w-3.5 h-3.5 shrink-0 ${
|
||||
doc?.isPinned ? 'text-status-warn' : 'text-accent'
|
||||
}`}
|
||||
/>
|
||||
<span>{doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onRequestMerge && doc?.isOwner && !inTrash && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShowActionsMenu(false);
|
||||
onRequestMerge(doc);
|
||||
}}
|
||||
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<GitMerge className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||
<span>Mit anderer Notiz zusammenführen…</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onToggleFocusMode && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShowActionsMenu(false);
|
||||
onToggleFocusMode();
|
||||
}}
|
||||
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
||||
>
|
||||
{focusMode ? (
|
||||
<Minimize2 className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||
) : (
|
||||
<Maximize2 className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||
)}
|
||||
<span className="flex-1">{focusMode ? 'Fokus-Modus beenden' : 'Fokus-Modus starten'}</span>
|
||||
<kbd className="hidden sm:inline text-[10px] text-faint font-mono">Strg+⇧+F</kbd>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShowActionsMenu(false);
|
||||
handleExportMarkdown();
|
||||
}}
|
||||
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||
<span>Als Markdown (.md) exportieren</span>
|
||||
</button>
|
||||
|
||||
{canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShowActionsMenu(false);
|
||||
onDeleteDoc(docId);
|
||||
}}
|
||||
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-status-err/10 text-left text-status-err font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-status-err shrink-0" />
|
||||
<span>Notiz löschen</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={inJournal ? 'flex flex-col bg-editor' : 'flex-1 flex flex-col h-full min-h-0 bg-editor overflow-y-auto'}
|
||||
data-sync-channel={engineState?.channel}
|
||||
>
|
||||
{/* Top Document Action Bar (the journal has one for all days) */}
|
||||
{!inJournal && (
|
||||
{desktopBar && actionsTarget && createPortal(actionsBlock, actionsTarget)}
|
||||
|
||||
{/* Top Document Action Bar (the journal has one for all days; large screens use the tab bar) */}
|
||||
{!inJournal && !desktopBar && (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between gap-2 px-4 sm:px-6 py-2 bg-editor border-b border-border">
|
||||
<div className="flex flex-1 items-center gap-2 min-w-0">
|
||||
{onBackMobile && (
|
||||
@@ -810,48 +1058,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
|
||||
{/* Breadcrumbs Navigation (Fokus-Modus) or Doc Indicator */}
|
||||
{zoomedBlock && breadcrumbs.length > 0 ? (
|
||||
<nav
|
||||
aria-label="Breadcrumb Navigation"
|
||||
className="flex items-center flex-wrap gap-1 text-xs text-muted"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomedBlockId(null)}
|
||||
className="hover:text-accent inline-flex items-center gap-1 font-medium transition-colors cursor-pointer"
|
||||
title="Zurück zum Hauptdokument"
|
||||
>
|
||||
<Home className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||
<span className="truncate max-w-[130px]">
|
||||
{title || doc?.title || 'Dokument'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{breadcrumbs.slice(1).map((crumb, idx) => {
|
||||
const isCurrent = idx === breadcrumbs.length - 2;
|
||||
return (
|
||||
<React.Fragment key={crumb.id || idx}>
|
||||
<ChevronRight className="w-3 h-3 text-faint shrink-0" />
|
||||
{isCurrent ? (
|
||||
<span
|
||||
className="font-bold text-accent truncate max-w-[160px]"
|
||||
title={crumb.title}
|
||||
>
|
||||
{crumb.title || 'Punkt'}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomedBlockId(crumb.id)}
|
||||
className="hover:text-main truncate max-w-[120px] transition-colors cursor-pointer"
|
||||
title={`Zoom zu "${crumb.title}"`}
|
||||
>
|
||||
{crumb.title || 'Punkt'}
|
||||
</button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
breadcrumbNav
|
||||
) : (
|
||||
<div
|
||||
className={`flex min-w-0 items-center gap-1.5 text-xs text-muted transition-opacity duration-200 ${
|
||||
@@ -902,131 +1109,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Action Buttons */}
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{!inTrash && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUndo}
|
||||
disabled={!canEdit || !engineState?.canUndo}
|
||||
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Rückgängig (Strg+Z)"
|
||||
aria-label="Rückgängig (Strg+Z)"
|
||||
>
|
||||
<Undo2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRedo}
|
||||
disabled={!canEdit || !engineState?.canRedo}
|
||||
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Wiederholen (Strg+Y)"
|
||||
aria-label="Wiederholen (Strg+Y)"
|
||||
>
|
||||
<Redo2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{doc && !inTrash && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenShareModal(doc)}
|
||||
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
|
||||
title="Notiz teilen und Berechtigungen"
|
||||
aria-label="Notiz teilen und Berechtigungen"
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="relative" ref={actionsMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowActionsMenu((prev) => !prev)}
|
||||
aria-expanded={showActionsMenu}
|
||||
aria-haspopup="menu"
|
||||
aria-label="Weitere Aktionen"
|
||||
title="Weitere Aktionen"
|
||||
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
|
||||
>
|
||||
<MoreHorizontal className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{showActionsMenu && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full mt-1.5 w-60 rounded-card bg-panel border border-border shadow-lg p-1 z-30 text-xs"
|
||||
>
|
||||
{!inTrash && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShowActionsMenu(false);
|
||||
void handleTogglePin();
|
||||
}}
|
||||
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<Pin
|
||||
className={`w-3.5 h-3.5 shrink-0 ${
|
||||
doc?.isPinned ? 'text-status-warn' : 'text-accent'
|
||||
}`}
|
||||
/>
|
||||
<span>{doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onRequestMerge && doc?.isOwner && !inTrash && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShowActionsMenu(false);
|
||||
onRequestMerge(doc);
|
||||
}}
|
||||
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<GitMerge className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||
<span>Mit anderer Notiz zusammenführen…</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShowActionsMenu(false);
|
||||
handleExportMarkdown();
|
||||
}}
|
||||
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||
<span>Als Markdown (.md) exportieren</span>
|
||||
</button>
|
||||
|
||||
{canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShowActionsMenu(false);
|
||||
onDeleteDoc(docId);
|
||||
}}
|
||||
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-status-err/10 text-left text-status-err font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-status-err shrink-0" />
|
||||
<span>Notiz löschen</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!desktopBar && actionsBlock}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1062,8 +1145,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
<div
|
||||
className={`w-full pl-2 pr-1 sm:px-8 flex-auto shrink-0 overflow-x-auto sm:overflow-x-visible [-webkit-overflow-scrolling:touch] ${
|
||||
inJournal ? 'py-3' : 'py-5'
|
||||
}`}
|
||||
} ${focusMode ? 'max-w-3xl mx-auto' : ''}`}
|
||||
>
|
||||
{desktopBar && zoomedBlock && breadcrumbs.length > 0 && <div className="mb-3">{breadcrumbNav}</div>}
|
||||
{zoomedBlock ? (
|
||||
/* =========================================================================
|
||||
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
|
||||
@@ -1159,6 +1243,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onOpenTask={onOpenTask}
|
||||
onTagClick={onSelectTag}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
onCopyBlockLink={handleCopyBlockLink}
|
||||
initialFocusedBlockId={initialFocusedBlockId}
|
||||
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
||||
lockedBlocks={lockedBlocks}
|
||||
@@ -1248,6 +1333,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onOpenTask={onOpenTask}
|
||||
onTagClick={onSelectTag}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
onCopyBlockLink={handleCopyBlockLink}
|
||||
initialFocusedBlockId={initialFocusedBlockId}
|
||||
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
||||
lockedBlocks={lockedBlocks}
|
||||
|
||||
@@ -45,7 +45,7 @@ export const JournalCalendar: React.FC<JournalCalendarProps> = ({ activeDate, en
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => shiftMonth(-1)}
|
||||
className="btn-icon h-9 w-9 sm:h-7 sm:w-7 shrink-0"
|
||||
className="h-9 w-9 sm:h-7 sm:w-7 shrink-0 rounded-btn flex items-center justify-center text-muted hover:text-main hover:bg-panel transition-colors cursor-pointer"
|
||||
title="Vorheriger Monat"
|
||||
aria-label="Vorheriger Monat"
|
||||
>
|
||||
@@ -54,7 +54,7 @@ export const JournalCalendar: React.FC<JournalCalendarProps> = ({ activeDate, en
|
||||
<select
|
||||
value={view.month}
|
||||
onChange={(e) => setView((v) => ({ ...v, month: Number(e.target.value) }))}
|
||||
className="flex-1 min-w-0 h-9 sm:h-7 px-1.5 input-field text-xs font-semibold"
|
||||
className="flex-1 min-w-0 h-9 sm:h-7 px-1.5 rounded-btn bg-panel text-main border-0 text-xs font-semibold outline-none focus:ring-1 focus:ring-accent cursor-pointer"
|
||||
aria-label="Monat"
|
||||
>
|
||||
{MONTHS.map((m, i) => (
|
||||
@@ -66,7 +66,7 @@ export const JournalCalendar: React.FC<JournalCalendarProps> = ({ activeDate, en
|
||||
<select
|
||||
value={view.year}
|
||||
onChange={(e) => setView((v) => ({ ...v, year: Number(e.target.value) }))}
|
||||
className="w-[4.5rem] h-9 sm:h-7 px-1.5 input-field text-xs font-semibold"
|
||||
className="w-[4.5rem] h-9 sm:h-7 px-1.5 rounded-btn bg-panel text-main border-0 text-xs font-semibold outline-none focus:ring-1 focus:ring-accent cursor-pointer"
|
||||
aria-label="Jahr"
|
||||
>
|
||||
{years.map((y) => (
|
||||
@@ -78,7 +78,7 @@ export const JournalCalendar: React.FC<JournalCalendarProps> = ({ activeDate, en
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => shiftMonth(1)}
|
||||
className="btn-icon h-9 w-9 sm:h-7 sm:w-7 shrink-0"
|
||||
className="h-9 w-9 sm:h-7 sm:w-7 shrink-0 rounded-btn flex items-center justify-center text-muted hover:text-main hover:bg-panel transition-colors cursor-pointer"
|
||||
title="Nächster Monat"
|
||||
aria-label="Nächster Monat"
|
||||
>
|
||||
|
||||
@@ -25,7 +25,7 @@ interface JournalViewProps {
|
||||
onDocMetadataChange: (updated: Document) => void;
|
||||
onDeleteDoc: (docId: string) => void;
|
||||
onSelectTag: (tag: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
|
||||
onOpenDocBlock?: (docId: string, blockId?: string) => void;
|
||||
onOpenTask?: (taskId: string) => void;
|
||||
onBackMobile: () => void;
|
||||
@@ -143,7 +143,7 @@ export const JournalView: React.FC<JournalViewProps> = ({
|
||||
const sharedCount = activeDoc?.sharedWith?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 bg-editor">
|
||||
<div className="flex-1 flex flex-col min-h-0 bg-editor view-enter">
|
||||
{/* One header for all days: the day in view and its sharing */}
|
||||
<div className="shrink-0 flex items-center gap-2 px-4 sm:px-6 py-2 border-b border-border">
|
||||
<button
|
||||
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Plus,
|
||||
ArrowLeft,
|
||||
ListTree,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../../lib/api';
|
||||
import { Document } from '../../lib/types';
|
||||
import { Document, DocumentBlock } from '../../lib/types';
|
||||
import { buildBlockLink } from '../../lib/wikiLink';
|
||||
|
||||
export interface LinkModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -100,6 +103,37 @@ export const LinkModal: React.FC<LinkModalProps> = ({
|
||||
);
|
||||
}, [docs, search]);
|
||||
|
||||
// Picking a single bullet of a note (link to that bullet)
|
||||
const [pickDoc, setPickDoc] = useState<Document | null>(null);
|
||||
const [pickBlocks, setPickBlocks] = useState<DocumentBlock[] | null>(null);
|
||||
const [blockSearch, setBlockSearch] = useState('');
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setPickDoc(null);
|
||||
setPickBlocks(null);
|
||||
setBlockSearch('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
const openBlockPicker = (doc: Document) => {
|
||||
setPickDoc(doc);
|
||||
setBlockSearch('');
|
||||
if (doc.blocks && doc.blocks.length > 0) {
|
||||
setPickBlocks(doc.blocks);
|
||||
return;
|
||||
}
|
||||
setPickBlocks(null);
|
||||
api
|
||||
.getDocument(doc.id)
|
||||
.then((full) => setPickBlocks(full.blocks || []))
|
||||
.catch(() => setPickBlocks([]));
|
||||
};
|
||||
const pickableBlocks = useMemo(() => {
|
||||
const q = blockSearch.trim().toLowerCase();
|
||||
return (pickBlocks || []).filter(
|
||||
(b) => b.content.trim() !== '' && b.type !== 'divider' && (!q || b.content.toLowerCase().includes(q))
|
||||
);
|
||||
}, [pickBlocks, blockSearch]);
|
||||
|
||||
const handleSelectNote = (doc: { title: string }) => {
|
||||
const noteTitle = doc.title.trim() || 'Notiz';
|
||||
onInsertLink(`[[${noteTitle}]]`);
|
||||
@@ -244,8 +278,59 @@ export const LinkModal: React.FC<LinkModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bullets of one note: link to a single bullet */}
|
||||
{tab === 'note' && pickDoc && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickDoc(null)}
|
||||
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 shrink-0"
|
||||
title="Zurück zur Notizliste"
|
||||
aria-label="Zurück zur Notizliste"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span className="text-xs font-semibold text-main truncate">
|
||||
Punkt in „{pickDoc.title || 'Unbenannte Notiz'}“ wählen
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={blockSearch}
|
||||
onChange={(e) => setBlockSearch(e.target.value)}
|
||||
placeholder="Punkte filtern…"
|
||||
className="input-field w-full h-11 sm:h-8 px-2.5 text-xs"
|
||||
aria-label="Punkte filtern"
|
||||
/>
|
||||
<div className="space-y-0.5 max-h-64 overflow-y-auto pr-0.5">
|
||||
{pickBlocks === null ? (
|
||||
<div className="p-4 text-xs text-muted flex items-center gap-2 justify-center">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-accent" />
|
||||
<span>Punkte werden geladen…</span>
|
||||
</div>
|
||||
) : pickableBlocks.length === 0 ? (
|
||||
<div className="p-4 text-xs text-muted text-center">Keine passenden Punkte.</div>
|
||||
) : (
|
||||
pickableBlocks.map((b) => (
|
||||
<button
|
||||
key={b.id}
|
||||
type="button"
|
||||
onClick={() => onInsertLink(buildBlockLink(pickDoc.title || 'Notiz', b.id, b.content))}
|
||||
className="w-full text-left min-h-[44px] sm:min-h-0 py-1.5 pr-2 rounded-btn text-xs text-main hover:bg-panel-elevated hover:text-accent flex items-start gap-1.5 cursor-pointer"
|
||||
style={{ paddingLeft: `${0.5 + Math.min(b.level || 0, 5) * 0.875}rem` }}
|
||||
>
|
||||
<span className="text-faint shrink-0">•</span>
|
||||
<span className="line-clamp-2 break-words">{b.content}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note List Section (Matching Active Shares list in UnifiedShareModal) */}
|
||||
{tab === 'note' && (
|
||||
{tab === 'note' && !pickDoc && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5">
|
||||
<span>Verfügbare Notizen</span>
|
||||
@@ -289,13 +374,27 @@ export const LinkModal: React.FC<LinkModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-action text-xs h-7 px-2.5 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 text-accent cursor-pointer shrink-0"
|
||||
>
|
||||
<span>Einfügen</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openBlockPicker(d as Document);
|
||||
}}
|
||||
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 text-muted hover:text-accent"
|
||||
title="Einen einzelnen Punkt dieser Notiz verlinken"
|
||||
aria-label="Punkt dieser Notiz wählen"
|
||||
>
|
||||
<ListTree className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-action text-xs h-7 px-2.5 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 text-accent cursor-pointer"
|
||||
>
|
||||
<span>Einfügen</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -18,13 +18,13 @@ import {
|
||||
ExternalLink,
|
||||
Maximize2,
|
||||
Link as LinkIcon,
|
||||
FileText,
|
||||
Lock,
|
||||
AlertTriangle,
|
||||
Image as ImageIcon,
|
||||
Mic,
|
||||
AudioLines,
|
||||
FileOutput,
|
||||
Link2,
|
||||
Type,
|
||||
} from 'lucide-react';
|
||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
@@ -94,7 +94,9 @@ export interface OutlinerBlockProps {
|
||||
onBlurBlock?: (blockId: string) => void;
|
||||
onOpenTask?: (taskId: string) => void;
|
||||
onTagClick?: (tag: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
|
||||
/** Copies a link to this bullet ([[Notiz#^id|…]]) */
|
||||
onCopyBlockLink?: (blockId: string) => void;
|
||||
onDragStartBlock?: (index: number) => void;
|
||||
onDragOverBlock?: (e: React.DragEvent, index: number) => void;
|
||||
onDropBlock?: (index: number) => void;
|
||||
@@ -104,6 +106,8 @@ export interface OutlinerBlockProps {
|
||||
onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void;
|
||||
/** Number or letter of a numbered-list item ("1.", "b.", "iii.") */
|
||||
listMarker?: string;
|
||||
/** Another bullet on the same level follows: Enter in this empty bullet adds a bullet instead of outdenting */
|
||||
siblingFollows?: boolean;
|
||||
lockedBy?: string; // someone else is editing this block
|
||||
canAppendBelow?: boolean; // the user may edit the note, so Enter in a locked block still adds one below
|
||||
onResolveConflict?: (blockId: string, accept: boolean) => void;
|
||||
@@ -144,6 +148,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
onOpenTask,
|
||||
onTagClick,
|
||||
onWikiLinkClick,
|
||||
onCopyBlockLink,
|
||||
onDragStartBlock,
|
||||
onDragOverBlock,
|
||||
onDropBlock,
|
||||
@@ -152,6 +157,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
registerTextareaRef,
|
||||
onPasteMultiLines,
|
||||
listMarker,
|
||||
siblingFollows = false,
|
||||
lockedBy,
|
||||
canAppendBelow,
|
||||
onResolveConflict,
|
||||
@@ -622,6 +628,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
val = val.replace(/^\[[xX]\]\s/, '');
|
||||
typeOverride = 'todo';
|
||||
checkedOverride = true;
|
||||
} else if (val === '- ' || val === '* ' || val === '+ ') {
|
||||
// Markdown list marker typed into an empty block: it already is a bullet, drop the marker
|
||||
val = '';
|
||||
if (!['text', 'bullet', 'paragraph'].includes(block.type || 'text')) typeOverride = 'bullet';
|
||||
} else if ((val === '1. ' || val === '1) ') && block.type !== 'numbered') {
|
||||
// "1. " typed into an empty bullet starts a numbered list
|
||||
val = '';
|
||||
@@ -954,13 +964,13 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
|
||||
const minLevel = levelOffset || 0;
|
||||
if (localContent === '') {
|
||||
if (Math.max(0, block.level || 0) > minLevel) {
|
||||
if (Math.max(0, block.level || 0) > minLevel && !siblingFollows) {
|
||||
flushDebounce(localContent);
|
||||
onIndentBlockRef.current(block.id, -1);
|
||||
return;
|
||||
}
|
||||
// Enter in an empty numbered item at the top ends the list
|
||||
if (block.type === 'numbered') {
|
||||
// Enter in an empty last numbered item at the top ends the list
|
||||
if (block.type === 'numbered' && !siblingFollows) {
|
||||
flushDebounce('', { type: 'text' });
|
||||
return;
|
||||
}
|
||||
@@ -1061,9 +1071,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}, [localContent]);
|
||||
|
||||
// #Hashtags are rendered inline inside the text, so only links need the badge bar below
|
||||
// Wiki links are readable and clickable in the text itself, so only web links get a chip
|
||||
const hasSpecialTokens = tokens.some(
|
||||
(t) =>
|
||||
(t.startsWith('[[') && t.endsWith(']]')) ||
|
||||
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
|
||||
(/^https?:\/\//.test(t) && t !== previewUrl)
|
||||
);
|
||||
@@ -1380,7 +1390,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
>
|
||||
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
|
||||
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
|
||||
{localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}
|
||||
{localContent ? renderInlineMarkdown(localContent, { onTagClick, onWikiLinkClick }) : '\u200b'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1500,21 +1510,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
{hasSpecialTokens && !isFocused && (
|
||||
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
||||
{tokens.map((part, idx) => {
|
||||
if (part.startsWith('[[') && part.endsWith(']]')) {
|
||||
const page = part.slice(2, -2).trim();
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => onWikiLinkClick?.(page)}
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-info bg-status-info/10 hover:bg-status-info/20 rounded-btn transition-colors"
|
||||
title={`Notiz öffnen: ${page}`}
|
||||
>
|
||||
<FileText className="w-2.5 h-2.5 opacity-80" />
|
||||
<span>{page}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (part.startsWith('[[')) return null;
|
||||
const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/);
|
||||
if (mdMatch && safeHref(mdMatch[2])) {
|
||||
const label = mdMatch[1];
|
||||
@@ -1617,8 +1613,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
: 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-events-none group-hover:pointer-events-auto focus-within:pointer-events-auto'
|
||||
}`}
|
||||
>
|
||||
{/* Convert back to bullet for headings, callouts, divider */}
|
||||
{canEdit && ['callout', 'h1', 'h2', 'h3', 'divider'].includes(block.type) && (
|
||||
{/* Convert back to bullet for headings, callouts, dividers, code and numbered items */}
|
||||
{canEdit && ['callout', 'h1', 'h2', 'h3', 'divider', 'code', 'numbered'].includes(block.type) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -1723,6 +1719,19 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link to this bullet */}
|
||||
{onCopyBlockLink && localContent.trim() !== '' && !mediaPicker && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopyBlockLink(block.id)}
|
||||
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
|
||||
title="Link zu diesem Punkt kopieren"
|
||||
aria-label="Link zu diesem Punkt kopieren"
|
||||
>
|
||||
<Link2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Branch into its own note */}
|
||||
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
|
||||
<button
|
||||
|
||||
@@ -26,7 +26,8 @@ export interface OutlinerTreeProps {
|
||||
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
|
||||
onOpenTask?: (taskId: string) => void;
|
||||
onTagClick?: (tag: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
|
||||
onCopyBlockLink?: (blockId: string) => void;
|
||||
initialFocusedBlockId?: string | null;
|
||||
onFocusedBlockChange?: (blockId: string | null) => void;
|
||||
lockedBlocks?: Map<string, string>; // blockId -> name of the user editing it (someone else)
|
||||
@@ -53,6 +54,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
onOpenTask,
|
||||
onTagClick,
|
||||
onWikiLinkClick,
|
||||
onCopyBlockLink,
|
||||
initialFocusedBlockId,
|
||||
onFocusedBlockChange,
|
||||
lockedBlocks,
|
||||
@@ -242,12 +244,31 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
|
||||
const listLabels = useMemo(() => numberedLabels(blocks), [blocks]);
|
||||
|
||||
// Whether another bullet on the same level follows (before the branch ends); an empty bullet
|
||||
// is then not outdented by Enter
|
||||
const siblingFollows = useCallback(
|
||||
(index: number) => {
|
||||
const level = blocks[index]?.level || 0;
|
||||
for (let i = index + 1; i < blocks.length; i++) {
|
||||
const l = blocks[i].level || 0;
|
||||
if (l < level) return false;
|
||||
if (l === level) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[blocks]
|
||||
);
|
||||
|
||||
const handleCreateBlockBelow = useCallback(
|
||||
(afterBlockId: string, newContent: string = '') => {
|
||||
if (!canEdit) return;
|
||||
const idx = blocks.findIndex((b) => b.id === afterBlockId);
|
||||
const currentBlock = idx >= 0 ? blocks[idx] : null;
|
||||
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
|
||||
const isHeading =
|
||||
(currentBlock?.type === 'h1' || currentBlock?.type === 'h2' || currentBlock?.type === 'h3') &&
|
||||
!currentBlock.collapsed;
|
||||
// Enter on an open heading starts its section: the new bullet goes below it as a child
|
||||
const level = currentBlock ? Math.max(0, currentBlock.level || 0) + (isHeading ? 1 : 0) : 0;
|
||||
// Numbered lists continue on Enter (an empty item ends the list, see OutlinerBlock)
|
||||
const inheritedType: BlockType = currentBlock?.type === 'numbered' ? 'numbered' : 'text';
|
||||
|
||||
@@ -611,6 +632,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
block={block}
|
||||
index={index}
|
||||
isLast={isLast}
|
||||
siblingFollows={siblingFollows(index)}
|
||||
maxAllowedLevel={maxAllowedLevel}
|
||||
hasChildren={hasChildren}
|
||||
isFocused={focusedBlockId === block.id}
|
||||
@@ -636,6 +658,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
onOpenTask={onOpenTask}
|
||||
onTagClick={onTagClick}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
onCopyBlockLink={onCopyBlockLink}
|
||||
onDragStartBlock={handleDragStartBlock}
|
||||
onDragOverBlock={handleDragOverBlock}
|
||||
onDropBlock={handleDropBlock}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
CalendarDays,
|
||||
Minimize2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Document,
|
||||
@@ -53,7 +54,7 @@ import { PresenceAvatars } from './PresenceAvatars';
|
||||
import { TrashView } from './TrashView';
|
||||
import { JournalView } from './JournalView';
|
||||
import { JournalCalendar } from './JournalCalendar';
|
||||
import { isJournalEnabled, todayISO } from '../../lib/journal';
|
||||
import { isJournalEnabled, readWorkspaceSession, todayISO, writeWorkspaceSession } from '../../lib/journal';
|
||||
import { MergeDialog } from './MergeDialog';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
import { extractHashtags } from '../../lib/hashtags';
|
||||
@@ -265,7 +266,7 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
||||
mergeHoverId === d.id
|
||||
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
|
||||
: selectedDocId === d.id && subView === 'editor'
|
||||
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||
? 'bg-panel-elevated text-main font-semibold border-transparent'
|
||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
>
|
||||
@@ -319,10 +320,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
return list.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
||||
}, [docs, serverTagsSummary]);
|
||||
|
||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(initialDocId || null);
|
||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(
|
||||
initialDocId || readWorkspaceSession().docId || null
|
||||
);
|
||||
const [targetBlockId, setTargetBlockId] = useState<string | null>(initialBlockId || null);
|
||||
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState<string | null>(null);
|
||||
const [subView, setSubView] = useState<WorkspaceSubView>('editor');
|
||||
// Journal open and the last regular note survive switching to other tabs (the view remounts)
|
||||
const [subView, setSubView] = useState<WorkspaceSubView>(() =>
|
||||
!initialDocId && isJournalEnabled() && readWorkspaceSession().journal ? 'journal' : 'editor'
|
||||
);
|
||||
const [trash, setTrash] = useState<TrashedDocument[]>([]);
|
||||
// Bumped after a restore so the open editor reloads the note without the trash banner
|
||||
const [editorNonce, setEditorNonce] = useState(0);
|
||||
@@ -398,7 +404,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}
|
||||
setShowNewFolderModal(true);
|
||||
};
|
||||
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(Boolean(initialDocId));
|
||||
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(
|
||||
Boolean(initialDocId) || subView === 'journal'
|
||||
);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem('mindsift:workspace_sidebar_collapsed') === 'true';
|
||||
@@ -420,6 +428,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
|
||||
const [docSaveError, setDocSaveError] = useState<string | null>(null);
|
||||
const [docHeaderStatus, setDocHeaderStatus] = useState<DocHeaderStatus | null>(null);
|
||||
const [docActionsTarget, setDocActionsTarget] = useState<HTMLDivElement | null>(null);
|
||||
// Distraction-free mode: only the note, full window, reading width
|
||||
const [focusMode, setFocusMode] = useState(false);
|
||||
// Focus mode: the folder sidebar slides in from the left (button, title click or mouse at the edge)
|
||||
const [zenSidebar, setZenSidebar] = useState<'closed' | 'click' | 'hover'>('closed');
|
||||
const [zenActionsTarget, setZenActionsTarget] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
|
||||
// Drag and Drop state
|
||||
@@ -473,12 +487,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
const loadWorkspaceData = useCallback(async () => {
|
||||
try {
|
||||
const [docsRes, foldersRes, sharedFoldersRes, tagsRes] = await Promise.all([
|
||||
const [docsRes, foldersRes, sharedFoldersRes, tagsRes, journalRes] = await Promise.all([
|
||||
api.getDocuments(),
|
||||
api.getFolders(),
|
||||
api.getSharedFolders().catch(() => []),
|
||||
api.getWorkspaceTags().catch(() => []),
|
||||
isJournalEnabled() ? api.getJournal().catch(() => [] as JournalDay[]) : Promise.resolve([] as JournalDay[]),
|
||||
]);
|
||||
const journalIds = new Set(journalRes.map((d) => d.docId));
|
||||
setDocs(docsRes);
|
||||
setFolders(foldersRes);
|
||||
setSharedFolders(sharedFoldersRes);
|
||||
@@ -488,7 +504,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
if (prevId && docsRes.some((d) => d.id === prevId)) {
|
||||
return prevId;
|
||||
}
|
||||
return docsRes.length > 0 ? docsRes[0].id : null;
|
||||
// Not a journal day: those open in the journal
|
||||
const first = docsRes.find((d) => !(d.isOwner && journalIds.has(d.id)));
|
||||
return first ? first.id : null;
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -620,6 +638,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setMobileShowDetail(true);
|
||||
}, [reloadJournal]);
|
||||
|
||||
// A second click closes the journal and shows the regular note again
|
||||
const toggleJournal = useCallback(() => {
|
||||
if (subView === 'journal') {
|
||||
setSubView('editor');
|
||||
setSelectedDocId((prev) => prev ?? treeDocsRef.current[0]?.id ?? null);
|
||||
} else {
|
||||
openJournal();
|
||||
}
|
||||
}, [subView, openJournal]);
|
||||
|
||||
const openTrash = useCallback(() => {
|
||||
reloadTrash();
|
||||
setSubView('trash');
|
||||
@@ -917,10 +945,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
};
|
||||
|
||||
const handleWikiLinkClick = useCallback(
|
||||
(pageTitle: string) => {
|
||||
(pageTitle: string, blockId?: string) => {
|
||||
const clean = pageTitle.trim().toLowerCase();
|
||||
const match = docs.find((d) => d.title.trim().toLowerCase() === clean);
|
||||
if (match) {
|
||||
if (match && blockId) {
|
||||
handleJumpToDocBlock(match.id, blockId);
|
||||
} else if (match) {
|
||||
setSelectedDocId(match.id);
|
||||
setSubView('editor');
|
||||
setMobileShowDetail(true);
|
||||
@@ -928,7 +958,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
handleCreateDocument('');
|
||||
}
|
||||
},
|
||||
[docs, handleCreateDocument]
|
||||
[docs, handleCreateDocument, handleJumpToDocBlock]
|
||||
);
|
||||
|
||||
const openDocShareModal = useCallback(async (doc: Document) => {
|
||||
@@ -1004,6 +1034,34 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
() => (journalDocIds.size ? docs.filter((d) => !(d.isOwner && journalDocIds.has(d.id))) : docs),
|
||||
[docs, journalDocIds]
|
||||
);
|
||||
const treeDocsRef = useRef(treeDocs);
|
||||
treeDocsRef.current = treeDocs;
|
||||
|
||||
useEffect(() => {
|
||||
writeWorkspaceSession({ journal: subView === 'journal', docId: selectedDocId });
|
||||
}, [subView, selectedDocId]);
|
||||
|
||||
const canFocus = subView === 'editor' && Boolean(selectedDocId);
|
||||
useEffect(() => {
|
||||
if (!canFocus) setFocusMode(false);
|
||||
}, [canFocus]);
|
||||
// Leaving focus mode or picking another note closes the sliding sidebar
|
||||
useEffect(() => {
|
||||
setZenSidebar('closed');
|
||||
}, [focusMode, selectedDocId]);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'f' && canFocus) {
|
||||
e.preventDefault();
|
||||
setFocusMode((v) => !v);
|
||||
} else if (e.key === 'Escape' && focusMode && !e.defaultPrevented) {
|
||||
if (zenSidebar !== 'closed') setZenSidebar('closed');
|
||||
else setFocusMode(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [canFocus, focusMode, zenSidebar]);
|
||||
|
||||
const allFolderPaths = useMemo(() => {
|
||||
const set = new Set<string>(folders);
|
||||
@@ -1389,10 +1447,18 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
{subView === 'editor' && selectedDocId ? (
|
||||
<>
|
||||
{/* Fixed order: people first (they come and go without moving the buttons), status, actions */}
|
||||
{docHeaderStatus && (
|
||||
<PresenceAvatars
|
||||
people={docHeaderStatus.otherPeople}
|
||||
avatars={docHeaderStatus.avatarsById}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Live connection pill */}
|
||||
{docHeaderStatus?.showLive && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
|
||||
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel text-muted"
|
||||
title={
|
||||
docHeaderStatus.channel === 'ws'
|
||||
? 'Live-Verbindung über WebSocket'
|
||||
@@ -1416,14 +1482,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Who else has this note open */}
|
||||
{docHeaderStatus && (
|
||||
<PresenceAvatars
|
||||
people={docHeaderStatus.otherPeople}
|
||||
avatars={docHeaderStatus.avatarsById}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Share pill */}
|
||||
{selectedDocObj &&
|
||||
!selectedDocObj.isArchived &&
|
||||
@@ -1442,7 +1500,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDocShareModal(selectedDocObj)}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
|
||||
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel font-semibold text-accent hover:bg-panel-elevated transition-colors cursor-pointer"
|
||||
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
|
||||
>
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
@@ -1452,7 +1510,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDocShareModal(selectedDocObj)}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
|
||||
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel font-medium text-muted hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
|
||||
title="Teilen & Berechtigungen öffnen"
|
||||
>
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
@@ -1461,16 +1519,26 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Save Status Pill */}
|
||||
{/* Save status: icon only, the text is the tooltip */}
|
||||
<div
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${
|
||||
role="img"
|
||||
aria-label={
|
||||
docSaveStatus === 'saved'
|
||||
? 'bg-canvas border-border text-muted'
|
||||
? 'Gespeichert'
|
||||
: docSaveStatus === 'saving'
|
||||
? 'bg-panel border-accent/40 text-accent animate-pulse'
|
||||
? 'Speichert…'
|
||||
: docSaveStatus === 'unsaved'
|
||||
? 'bg-status-warn/10 border-status-warn/30 text-status-warn'
|
||||
: 'bg-status-err/10 border-status-err/30 text-status-err'
|
||||
? 'Ungespeichert'
|
||||
: 'Fehler beim Speichern'
|
||||
}
|
||||
className={`inline-flex items-center justify-center h-7 w-7 rounded-btn transition-colors ${
|
||||
docSaveStatus === 'saved'
|
||||
? 'bg-panel'
|
||||
: docSaveStatus === 'saving'
|
||||
? 'bg-panel animate-pulse'
|
||||
: docSaveStatus === 'unsaved'
|
||||
? 'bg-status-warn/10'
|
||||
: 'bg-status-err/10'
|
||||
}`}
|
||||
title={
|
||||
docSaveStatus === 'saved'
|
||||
@@ -1485,119 +1553,201 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
{docSaveStatus === 'saved' && (
|
||||
<>
|
||||
<Check className="w-3.5 h-3.5 text-status-ok" />
|
||||
<span>Gespeichert</span>
|
||||
</>
|
||||
)}
|
||||
{docSaveStatus === 'saving' && (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
|
||||
<span>Speichert…</span>
|
||||
</>
|
||||
)}
|
||||
{docSaveStatus === 'unsaved' && (
|
||||
<>
|
||||
<Clock className="w-3.5 h-3.5 text-status-warn" />
|
||||
<span>Ungespeichert</span>
|
||||
</>
|
||||
)}
|
||||
{docSaveStatus === 'error' && (
|
||||
<>
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-status-err" />
|
||||
<span>Fehler</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{/* The open note renders undo/redo, share and its menu here on large screens */}
|
||||
{subView === 'editor' && selectedDocId && (
|
||||
<span className="hidden lg:block w-px h-5 bg-border mx-0.5" aria-hidden />
|
||||
)}
|
||||
<div ref={setDocActionsTarget} className="hidden lg:flex items-center" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Workspace Split Pane */}
|
||||
<div className="flex flex-1 min-h-0 bg-editor rounded-card border border-border overflow-hidden">
|
||||
{/* LEFT MASTER SIDEBAR */}
|
||||
<aside
|
||||
className={`${
|
||||
(subView !== 'editor' && subView !== 'trash' && subView !== 'journal') || isSidebarCollapsed
|
||||
? 'hidden'
|
||||
: mobileShowDetail
|
||||
? 'hidden lg:flex'
|
||||
: 'flex'
|
||||
} w-full lg:w-72 xl:w-80 shrink-0 flex-col bg-sidebar border-r border-border transition-all duration-200`}
|
||||
>
|
||||
{/* Sidebar Header & Actions */}
|
||||
<div className="p-3 border-b border-border space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xs font-bold text-main uppercase tracking-wider">
|
||||
Notizen & Outliner
|
||||
</h2>
|
||||
{/* On phones these are in the menu at the top (and the + button), so not repeated here */}
|
||||
<div className="hidden sm:flex items-center gap-1">
|
||||
<div
|
||||
className={
|
||||
focusMode
|
||||
? 'fixed inset-0 z-[45] flex bg-editor focus-mode-enter'
|
||||
: 'flex flex-1 min-h-0 bg-editor rounded-card border border-border overflow-hidden'
|
||||
}
|
||||
>
|
||||
{focusMode && (
|
||||
<>
|
||||
{/* Header slides in when the mouse touches the top edge (large screens) */}
|
||||
<div className="hidden lg:block fixed top-0 inset-x-0 h-3 z-[46] group">
|
||||
<div className="absolute top-0 inset-x-0 flex items-center gap-2 px-6 py-2 bg-panel/95 backdrop-blur-sm border-b border-border shadow-panel -translate-y-full group-hover:translate-y-0 group-focus-within:translate-y-0 transition-transform duration-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleOpenNewFolder('', e)}
|
||||
className="btn-action p-1 text-muted hover:text-main"
|
||||
title="Neuer Ordner"
|
||||
onClick={() => setZenSidebar((v) => (v === 'closed' ? 'click' : 'closed'))}
|
||||
className="btn-icon h-7 w-7 shrink-0"
|
||||
title="Ordner und Notizen einblenden"
|
||||
aria-label="Ordner und Notizen einblenden"
|
||||
aria-expanded={zenSidebar !== 'closed'}
|
||||
>
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
<FolderOpen className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCreateDocument('')}
|
||||
className="h-7 px-2.5 bg-canvas hover:bg-panel border border-border border-dashed rounded-btn text-xs text-muted hover:text-main inline-flex items-center gap-1 transition-colors cursor-pointer"
|
||||
title="Neue Notiz anlegen"
|
||||
onClick={() => setZenSidebar((v) => (v === 'closed' ? 'click' : 'closed'))}
|
||||
className="flex-1 min-w-0 truncate text-left text-xs font-medium text-main hover:text-accent cursor-pointer"
|
||||
title="Andere Notiz wählen"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Notiz</span>
|
||||
{selectedDocObj?.icon} {selectedDocObj?.title}
|
||||
</button>
|
||||
<div ref={setZenActionsTarget} className="flex items-center" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFocusMode(false)}
|
||||
className="btn-action h-7 px-2.5 text-xs inline-flex items-center gap-1.5"
|
||||
title="Fokus-Modus beenden (Esc)"
|
||||
>
|
||||
<Minimize2 className="w-3.5 h-3.5" />
|
||||
Beenden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wide screens: touching the left edge opens the sidebar (the note sits far from it) */}
|
||||
<div
|
||||
className="hidden xl:block fixed left-0 inset-y-0 w-2 z-[46]"
|
||||
onMouseEnter={() => setZenSidebar((v) => (v === 'closed' ? 'hover' : v))}
|
||||
aria-hidden
|
||||
/>
|
||||
{zenSidebar === 'click' && (
|
||||
<div className="fixed inset-0 z-[46] bg-overlay/30" onClick={() => setZenSidebar('closed')} aria-hidden />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFocusMode(false)}
|
||||
className="lg:hidden fixed bottom-4 right-4 z-[46] h-12 w-12 rounded-full bg-panel border border-border shadow-panel flex items-center justify-center text-main"
|
||||
title="Fokus-Modus beenden"
|
||||
aria-label="Fokus-Modus beenden"
|
||||
>
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* LEFT MASTER SIDEBAR */}
|
||||
<aside
|
||||
onMouseLeave={() => setZenSidebar((v) => (v === 'hover' ? 'closed' : v))}
|
||||
className={
|
||||
focusMode
|
||||
? `fixed left-0 inset-y-0 z-[47] w-80 max-w-[85vw] flex flex-col bg-sidebar border-r border-border shadow-panel transition-transform duration-300 ease-out ${
|
||||
zenSidebar !== 'closed' ? 'translate-x-0' : '-translate-x-full'
|
||||
}`
|
||||
: `${
|
||||
(subView !== 'editor' && subView !== 'trash' && subView !== 'journal') || isSidebarCollapsed
|
||||
? 'hidden'
|
||||
: mobileShowDetail
|
||||
? 'hidden lg:flex'
|
||||
: 'flex'
|
||||
} w-full lg:w-72 xl:w-80 shrink-0 flex-col bg-sidebar border-r border-border transition-all duration-200`
|
||||
}
|
||||
>
|
||||
{/* Sidebar header: search and the create buttons in one row */}
|
||||
<div className="p-3 border-b border-border flex items-center gap-1.5">
|
||||
{/* Opens the global full-text search, which also finds the content of every bullet */}
|
||||
{onOpenSearch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSearch}
|
||||
className="input-field w-full h-11 sm:h-7 px-2.5 flex items-center gap-2 text-xs text-faint hover:text-main cursor-pointer"
|
||||
className="input-field flex-1 min-w-0 h-11 sm:h-8 px-2.5 flex items-center gap-2 text-xs text-faint hover:text-main cursor-pointer"
|
||||
title="Globale Suche (Strg+K / ⌘K oder /)"
|
||||
>
|
||||
<Search className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="flex-1 text-left truncate">Notizen durchsuchen…</span>
|
||||
<span className="hidden sm:inline font-mono text-[10px]">⌘K</span>
|
||||
<span className="hidden xl:inline font-mono text-[10px]">⌘K</span>
|
||||
</button>
|
||||
)}
|
||||
{/* On phones these are in the menu at the top (and the + button), so not repeated here */}
|
||||
<div className="hidden sm:flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleOpenNewFolder('', e)}
|
||||
className="btn-icon h-8 w-8"
|
||||
title="Neuer Ordner"
|
||||
aria-label="Neuer Ordner"
|
||||
>
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCreateDocument('')}
|
||||
className="btn-icon h-8 w-8"
|
||||
title="Neue Notiz anlegen"
|
||||
aria-label="Neue Notiz anlegen"
|
||||
>
|
||||
<FilePlus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Navigation Tree */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
||||
{/* Journal on top; while open, its calendar stays below it */}
|
||||
{/* Journal on top; open, it becomes a panel with the calendar (click the heading to close).
|
||||
Panel and calendar animate open (grid rows 0fr → 1fr) instead of jumping. */}
|
||||
{journalEnabled && (
|
||||
<div className="border-b border-border pb-1.5 -mb-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openJournal}
|
||||
className={`w-full flex items-center gap-2 px-2 h-11 sm:h-8 rounded-btn text-xs border transition-colors cursor-pointer ${
|
||||
subView === 'journal'
|
||||
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||
<div>
|
||||
<div
|
||||
className={`rounded-card border-2 transition-[background-color,border-color,padding] duration-300 ease-out ${
|
||||
subView === 'journal' ? 'border-transparent bg-panel-elevated p-2.5' : 'border-transparent p-0'
|
||||
}`}
|
||||
>
|
||||
<CalendarDays className="w-3.5 h-3.5 text-accent" />
|
||||
<span className="flex-1 text-left">Journal</span>
|
||||
{subView === 'journal' ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted" />
|
||||
)}
|
||||
</button>
|
||||
{subView === 'journal' && (
|
||||
<div className="hidden lg:block px-1 pt-2 pb-1">
|
||||
<JournalCalendar
|
||||
activeDate={journalActive}
|
||||
entryDates={new Set(journalDays.filter((d) => !d.empty).map((d) => d.date))}
|
||||
onSelect={jumpToJournalDay}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleJournal}
|
||||
aria-expanded={subView === 'journal'}
|
||||
title={subView === 'journal' ? 'Journal schließen' : 'Journal öffnen'}
|
||||
className={`w-full flex items-center gap-2 text-left cursor-pointer rounded-btn transition-all duration-300 ${
|
||||
subView === 'journal'
|
||||
? 'h-11 sm:h-7 px-0.5 text-sm font-bold text-main'
|
||||
: 'h-11 sm:h-8 px-2 text-xs text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
>
|
||||
<CalendarDays
|
||||
className={`text-accent transition-all duration-300 ${subView === 'journal' ? 'w-4 h-4' : 'w-3.5 h-3.5'}`}
|
||||
/>
|
||||
<span>Journal</span>
|
||||
</button>
|
||||
<div
|
||||
className={`hidden lg:grid transition-[grid-template-rows,opacity] duration-300 ease-out ${
|
||||
subView === 'journal' ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
|
||||
}`}
|
||||
aria-hidden={subView !== 'journal'}
|
||||
>
|
||||
<div className="overflow-hidden min-h-0">
|
||||
<div className="pt-2">
|
||||
<JournalCalendar
|
||||
activeDate={journalActive}
|
||||
entryDates={new Set(journalDays.filter((d) => !d.empty).map((d) => d.date))}
|
||||
onSelect={jumpToJournalDay}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`h-px bg-border mt-1.5 transition-opacity duration-300 ${
|
||||
subView === 'journal' ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{pinnedDocs.length === 0 && folderTree.length === 0 && rootDocs.length === 0 ? (
|
||||
@@ -1628,7 +1778,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
mergeHoverId === d.id
|
||||
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
|
||||
: selectedDocId === d.id && subView === 'editor'
|
||||
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||
? 'bg-panel-elevated text-main font-semibold border-transparent'
|
||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
>
|
||||
@@ -1691,7 +1841,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
mergeHoverId === d.id
|
||||
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
|
||||
: selectedDocId === d.id && subView === 'editor'
|
||||
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||
? 'bg-panel-elevated text-main font-semibold border-transparent'
|
||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
>
|
||||
@@ -1760,7 +1910,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
onClick={openTrash}
|
||||
className={`w-full flex items-center gap-2 px-2 h-11 sm:h-8 rounded-btn text-xs border transition-colors cursor-pointer ${
|
||||
subView === 'trash'
|
||||
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||
? 'bg-panel-elevated text-main font-semibold border-transparent'
|
||||
: 'border-transparent text-muted hover:text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
>
|
||||
@@ -1774,7 +1924,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
{/* RIGHT DETAIL AREA (STRICTLY ISOLATED BY KEY) */}
|
||||
<main
|
||||
className={`${
|
||||
subView !== 'editor' || mobileShowDetail || isSidebarCollapsed ? 'flex' : 'hidden lg:flex'
|
||||
subView !== 'editor' || mobileShowDetail || isSidebarCollapsed || focusMode ? 'flex' : 'hidden lg:flex'
|
||||
} flex-1 flex-col min-w-0 h-full overflow-hidden bg-editor`}
|
||||
>
|
||||
{subView === 'journal' && journalEnabled ? (
|
||||
@@ -1852,6 +2002,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setDocSaveError(err || null);
|
||||
}}
|
||||
onHeaderStatusChange={setDocHeaderStatus}
|
||||
actionsTarget={focusMode ? zenActionsTarget : docActionsTarget}
|
||||
focusMode={focusMode}
|
||||
onToggleFocusMode={() => setFocusMode((v) => !v)}
|
||||
/>
|
||||
) : subView === 'tags' ? (
|
||||
/* =========================================================================
|
||||
|
||||
@@ -77,19 +77,14 @@ describe('CodeBlockEditor', () => {
|
||||
expect(updates[0].lang).toBe('yaml');
|
||||
expect(select.value).toBe('yaml');
|
||||
|
||||
// "Als Text" converts the code block back to a bullet
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Als Text' }));
|
||||
expect(updates[updates.length - 1]?.type).toBe('bullet');
|
||||
|
||||
unmount();
|
||||
|
||||
// Reader view (!canEdit): shows plain text instead of <select> and hides "Als Text"
|
||||
// Reader view (!canEdit): shows plain text instead of <select>
|
||||
const readerView = render(
|
||||
<CodeBlockEditor block={makeCodeBlock({ lang: 'yaml' })} canEdit={false} />
|
||||
);
|
||||
expect(readerView.container.querySelector('select')).toBeNull();
|
||||
expect(readerView.getByText('yaml')).toBeTruthy();
|
||||
expect(readerView.queryByText('Als Text')).toBeNull();
|
||||
});
|
||||
|
||||
it('copies the code content to the clipboard and shows "Kopiert!" for 1.5s', () => {
|
||||
@@ -108,14 +103,14 @@ describe('CodeBlockEditor', () => {
|
||||
fireEvent.click(copyBtn);
|
||||
|
||||
expect(writeTextMock).toHaveBeenCalledWith('echo "hello world"');
|
||||
expect(screen.getByText('Kopiert!')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Kopiert!' })).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Kopiert!')).toBeNull();
|
||||
expect(screen.getByText('Kopieren')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: 'Kopiert!' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Kopieren' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('indents by 2 spaces on Tab and outdents on Shift+Tab without indenting the bullet', () => {
|
||||
|
||||
@@ -207,6 +207,33 @@ describe('slash command menu and outliner fixes', () => {
|
||||
expect(onCreateBelow).toHaveBeenCalledWith('b1', '');
|
||||
});
|
||||
|
||||
it('keeps an empty indented bullet on its level when another bullet on that level follows', () => {
|
||||
const onIndent = vi.fn();
|
||||
const onCreateBelow = vi.fn();
|
||||
const { container } = render(
|
||||
<OutlinerBlock
|
||||
docId="d"
|
||||
block={{ id: 'b1', type: 'bullet', content: '', level: 1 } as DocumentBlock}
|
||||
index={0}
|
||||
isLast={false}
|
||||
siblingFollows
|
||||
maxAllowedLevel={2}
|
||||
hasChildren={false}
|
||||
isFocused
|
||||
canEdit
|
||||
onUpdateBlock={() => {}}
|
||||
onCreateBlockBelow={onCreateBelow}
|
||||
onDeleteBlock={() => {}}
|
||||
onIndentBlock={onIndent}
|
||||
onNavigateFocus={() => {}}
|
||||
registerTextareaRef={() => {}}
|
||||
/>
|
||||
);
|
||||
fireEvent.keyDown(container.querySelector('textarea') as HTMLTextAreaElement, { key: 'Enter' });
|
||||
expect(onIndent).not.toHaveBeenCalled();
|
||||
expect(onCreateBelow).toHaveBeenCalledWith('b1', '');
|
||||
});
|
||||
|
||||
it('inserts a bare URL for /link with a web address in an empty bullet, and [Text](url) otherwise', () => {
|
||||
const updates: DocumentBlock[] = [];
|
||||
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
|
||||
|
||||
@@ -571,3 +571,27 @@ fieldset:disabled :is(button, input, select, textarea) {
|
||||
--outline-min-text: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus mode: the note fades in over the app */
|
||||
@keyframes focus-mode-in {
|
||||
from { opacity: 0; transform: scale(0.99); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.focus-mode-enter {
|
||||
animation: focus-mode-in 300ms ease-in-out;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.focus-mode-enter { animation: none; }
|
||||
}
|
||||
|
||||
/* Views that replace each other (journal ↔ note) fade in instead of jumping */
|
||||
@keyframes view-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.view-enter {
|
||||
animation: view-in 280ms ease-out;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.view-enter { animation: none; }
|
||||
}
|
||||
@@ -10,6 +10,13 @@ describe('renderInlineMarkdown', () => {
|
||||
expect(strong?.textContent).toBe('fettem');
|
||||
});
|
||||
|
||||
it('renders inline code inside bold text', () => {
|
||||
const { container } = render(<div>{renderInlineMarkdown('Der Block **`services`** startet')}</div>);
|
||||
const code = container.querySelector('strong code');
|
||||
expect(code?.textContent).toBe('services');
|
||||
expect(container.textContent).toBe('Der Block services startet');
|
||||
});
|
||||
|
||||
it('renders italic text with <em> tag', () => {
|
||||
const { container } = render(<div>{renderInlineMarkdown('Text mit *kursivem* Wort')}</div>);
|
||||
const em = container.querySelector('em');
|
||||
|
||||
@@ -20,13 +20,27 @@ describe('parsePastedLines', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads headings, todos, bullets and dividers', () => {
|
||||
expect(parse('# Titel\nText danach\n- [x] erledigt\n- Punkt\n---')).toEqual([
|
||||
it('reads headings, todos, bullets and dividers and nests sections under headings', () => {
|
||||
expect(parse('# Titel\nText danach\n- [x] erledigt\n* Punkt\n + tiefer\n---\nOhne Abschnitt')).toEqual([
|
||||
{ type: 'h1', content: 'Titel', indent: 0 },
|
||||
{ type: 'text', content: 'Text danach', indent: 0 },
|
||||
{ type: 'todo', checked: true, content: 'erledigt', indent: 0 },
|
||||
{ type: 'text', content: 'Punkt', indent: 0 },
|
||||
{ type: 'text', content: 'Text danach', indent: 1 },
|
||||
{ type: 'todo', checked: true, content: 'erledigt', indent: 2 },
|
||||
{ type: 'text', content: 'Punkt', indent: 2 },
|
||||
{ type: 'text', content: 'tiefer', indent: 3 },
|
||||
{ type: 'divider', content: '---', indent: 0 },
|
||||
{ type: 'text', content: 'Ohne Abschnitt', indent: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('nests H2 below H1 and closes sections at the same rank', () => {
|
||||
expect(parse('# A\n## A1\n1. eins\n## A2\nText\n# B\n- b')).toEqual([
|
||||
{ type: 'h1', content: 'A', indent: 0 },
|
||||
{ type: 'h2', content: 'A1', indent: 1 },
|
||||
{ type: 'numbered', content: 'eins', indent: 2 },
|
||||
{ type: 'h2', content: 'A2', indent: 1 },
|
||||
{ type: 'text', content: 'Text', indent: 2 },
|
||||
{ type: 'h1', content: 'B', indent: 0 },
|
||||
{ type: 'text', content: 'b', indent: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -35,4 +49,17 @@ describe('parsePastedLines', () => {
|
||||
expect(items[0].content).toBe('```go\nfunc main() {}\n\n```');
|
||||
expect(items[1]).toEqual({ type: 'text', content: 'Danach', indent: 0 });
|
||||
});
|
||||
|
||||
it('nests a list under the paragraph that introduces it', () => {
|
||||
expect(parse('# Plan\nFolgende Punkte:\n\n- eins\n - tiefer\n- zwei\n\nDanach Text\n1. a\n2. b')).toEqual([
|
||||
{ type: 'h1', content: 'Plan', indent: 0 },
|
||||
{ type: 'text', content: 'Folgende Punkte:', indent: 1 },
|
||||
{ type: 'text', content: 'eins', indent: 2 },
|
||||
{ type: 'text', content: 'tiefer', indent: 3 },
|
||||
{ type: 'text', content: 'zwei', indent: 2 },
|
||||
{ type: 'text', content: 'Danach Text', indent: 1 },
|
||||
{ type: 'numbered', content: 'a', indent: 2 },
|
||||
{ type: 'numbered', content: 'b', indent: 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deviceName, urlBase64ToUint8Array } from '../push';
|
||||
|
||||
describe('push helpers', () => {
|
||||
it('decodes the VAPID key (base64url without padding)', () => {
|
||||
expect(Array.from(urlBase64ToUint8Array('AQID_-8'))).toEqual([1, 2, 3, 255, 239]);
|
||||
});
|
||||
|
||||
it('names the device', () => {
|
||||
expect(deviceName('Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0')).toBe('Firefox auf Linux');
|
||||
expect(deviceName('Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/129.0 Mobile Safari/537.36')).toBe('Chrome auf Android');
|
||||
expect(deviceName('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1')).toBe('Safari auf iOS');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatSharedThought } from '../shareTarget';
|
||||
|
||||
describe('formatSharedThought', () => {
|
||||
it('formats pure text correctly', () => {
|
||||
expect(formatSharedThought({ text: 'Spontaner Einfall' })).toBe('Spontaner Einfall');
|
||||
});
|
||||
|
||||
it('formats pure url correctly', () => {
|
||||
expect(formatSharedThought({ url: 'https://example.com/article' })).toBe('https://example.com/article');
|
||||
});
|
||||
|
||||
it('combines title and url when text is empty', () => {
|
||||
expect(formatSharedThought({ title: 'Toller Artikel', url: 'https://example.com/article' }))
|
||||
.toBe('Toller Artikel\nhttps://example.com/article');
|
||||
});
|
||||
|
||||
it('does not duplicate URL when text already contains it', () => {
|
||||
const input = {
|
||||
title: 'Artikel',
|
||||
text: 'Lies das hier: https://example.com/article unbedingt!',
|
||||
url: 'https://example.com/article',
|
||||
};
|
||||
expect(formatSharedThought(input))
|
||||
.toBe('Artikel\nLies das hier: https://example.com/article unbedingt!');
|
||||
});
|
||||
|
||||
it('does not duplicate URL when text is exactly the URL', () => {
|
||||
const input = {
|
||||
title: 'News',
|
||||
text: 'https://news.example.com',
|
||||
url: 'https://news.example.com',
|
||||
};
|
||||
expect(formatSharedThought(input))
|
||||
.toBe('News\nhttps://news.example.com');
|
||||
});
|
||||
|
||||
it('does not duplicate title if text starts with title', () => {
|
||||
const input = {
|
||||
title: 'MindSift Update',
|
||||
text: 'MindSift Update ist jetzt verfügbar.',
|
||||
};
|
||||
expect(formatSharedThought(input))
|
||||
.toBe('MindSift Update ist jetzt verfügbar.');
|
||||
});
|
||||
|
||||
it('combines title, text and distinct URL', () => {
|
||||
const input = {
|
||||
title: 'Empfehlung',
|
||||
text: 'Ein großartiges Tool für schnelles Erfassen.',
|
||||
url: 'https://mindsift.app',
|
||||
};
|
||||
expect(formatSharedThought(input))
|
||||
.toBe('Empfehlung\nEin großartiges Tool für schnelles Erfassen.\nhttps://mindsift.app');
|
||||
});
|
||||
|
||||
it('handles image with text', () => {
|
||||
const input = {
|
||||
title: 'Foto vom Ausflug',
|
||||
fileName: 'berge.jpg',
|
||||
hasMedia: true,
|
||||
};
|
||||
expect(formatSharedThought(input))
|
||||
.toBe('Foto vom Ausflug\n(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
|
||||
});
|
||||
|
||||
it('handles image only with filename', () => {
|
||||
const input = {
|
||||
fileName: 'screenshot.png',
|
||||
hasMedia: true,
|
||||
};
|
||||
expect(formatSharedThought(input))
|
||||
.toBe('[screenshot.png] (Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
|
||||
});
|
||||
|
||||
it('handles image only without filename', () => {
|
||||
const input = {
|
||||
hasMedia: true,
|
||||
};
|
||||
expect(formatSharedThought(input))
|
||||
.toBe('(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
|
||||
});
|
||||
|
||||
it('returns empty string if nothing is provided', () => {
|
||||
expect(formatSharedThought({})).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildBlockLink, parseWikiLink, wikiLinkText } from '../wikiLink';
|
||||
|
||||
describe('wiki links', () => {
|
||||
it('parses plain, labelled, section and bullet links', () => {
|
||||
expect(parseWikiLink('Homelab')).toEqual({ title: 'Homelab', blockId: undefined, label: undefined });
|
||||
expect(parseWikiLink('Homelab|hier')).toEqual({ title: 'Homelab', blockId: undefined, label: 'hier' });
|
||||
expect(parseWikiLink('Homelab#Abschnitt')).toEqual({ title: 'Homelab', blockId: undefined, label: undefined });
|
||||
expect(parseWikiLink('Homelab#^blk_a_1|Homelab › Backup')).toEqual({
|
||||
title: 'Homelab', blockId: 'blk_a_1', label: 'Homelab › Backup',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a bullet link that parses back', () => {
|
||||
const link = buildBlockLink('Homelab [neu]', 'blk_x', 'Backups liefen bisher nur manuell – das soll sich ändern #backup');
|
||||
expect(link).toBe('[[Homelab neu#^blk_x|Homelab neu › Backups liefen bisher nur manuell – das…]]');
|
||||
const parsed = parseWikiLink(link.slice(2, -2));
|
||||
expect(parsed.blockId).toBe('blk_x');
|
||||
expect(wikiLinkText(parsed)).toBe('Homelab neu › Backups liefen bisher nur manuell – das…');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Document,
|
||||
TrashedDocument,
|
||||
JournalDay,
|
||||
PushDevice,
|
||||
BackupFile,
|
||||
DataSection,
|
||||
ApiKeyScope,
|
||||
@@ -724,6 +725,27 @@ export const api = {
|
||||
return request<TrashedDocument[]>('/api/v1/documents/trash');
|
||||
},
|
||||
|
||||
async getPushKey(): Promise<{ enabled: boolean; publicKey?: string }> {
|
||||
return request('/api/v1/push/key');
|
||||
},
|
||||
|
||||
async getPushDevices(): Promise<PushDevice[]> {
|
||||
const res = await request<{ devices: PushDevice[] }>('/api/v1/push/devices');
|
||||
return res.devices || [];
|
||||
},
|
||||
|
||||
async subscribePush(sub: PushSubscriptionJSON, device: string): Promise<void> {
|
||||
await request('/api/v1/push/subscribe', { method: 'POST', body: JSON.stringify({ ...sub, device }) });
|
||||
},
|
||||
|
||||
async unsubscribePush(endpoint: string): Promise<void> {
|
||||
await request('/api/v1/push/unsubscribe', { method: 'POST', body: JSON.stringify({ endpoint }) });
|
||||
},
|
||||
|
||||
async testPush(): Promise<{ devices: number }> {
|
||||
return request('/api/v1/push/test', { method: 'POST' });
|
||||
},
|
||||
|
||||
/** Days of the own journal (newest first) with their notes. */
|
||||
async getJournal(): Promise<JournalDay[]> {
|
||||
const res = await request<{ days: JournalDay[] }>('/api/v1/journal');
|
||||
|
||||
@@ -60,3 +60,23 @@ export function monthGrid(year: number, month: number): (string | null)[][] {
|
||||
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
|
||||
return weeks;
|
||||
}
|
||||
|
||||
// Workspace state for this browser tab: is the journal open, which regular note was open last
|
||||
const WORKSPACE_SESSION_KEY = 'mindsift_workspace_view';
|
||||
|
||||
export function readWorkspaceSession(): { journal: boolean; docId: string | null } {
|
||||
try {
|
||||
const raw = JSON.parse(sessionStorage.getItem(WORKSPACE_SESSION_KEY) || '{}');
|
||||
return { journal: raw.journal === true, docId: typeof raw.docId === 'string' ? raw.docId : null };
|
||||
} catch {
|
||||
return { journal: false, docId: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeWorkspaceSession(state: { journal: boolean; docId: string | null }) {
|
||||
try {
|
||||
sessionStorage.setItem(WORKSPACE_SESSION_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// storage blocked: the view simply starts fresh
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { safeHref } from './safeUrl';
|
||||
import { parseWikiLink, wikiLinkText } from './wikiLink';
|
||||
|
||||
/**
|
||||
* Parses inline markdown:
|
||||
@@ -9,9 +10,11 @@ import { safeHref } from './safeUrl';
|
||||
* - `code`
|
||||
* - [label](url)
|
||||
* - #hashtag (only when `options.onTagClick` is given), rendered inline as a clickable chip
|
||||
* - [[wiki link]] shown by its label (clickable with `options.onWikiLinkClick`)
|
||||
*/
|
||||
export interface InlineMarkdownOptions {
|
||||
onTagClick?: (tag: string) => void;
|
||||
onWikiLinkClick?: (title: string, blockId?: string) => void;
|
||||
}
|
||||
|
||||
function renderInlineTokens(text: string, options?: InlineMarkdownOptions): React.ReactNode[] {
|
||||
@@ -19,7 +22,7 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
|
||||
|
||||
const tagAlt = options?.onTagClick ? '|#[a-zA-Z0-9äöüÄÖÜß_-]+' : '';
|
||||
const regex = new RegExp(
|
||||
'(`[^`]+`|\\*\\*[^*]+\\*\\*|__[^_]+__|~~[^~]+~~|\\*[^*]+\\*|_[^_]+_|\\[([^\\]]+)\\]\\(([^)]+)\\)' + tagAlt + ')',
|
||||
'(\\[\\[[^\\]]+\\]\\]|`[^`]+`|\\*\\*[^*]+\\*\\*|__[^_]+__|~~[^~]+~~|\\*[^*]+\\*|_[^_]+_|\\[([^\\]]+)\\]\\(([^)]+)\\)' + tagAlt + ')',
|
||||
'g'
|
||||
);
|
||||
|
||||
@@ -34,7 +37,31 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
|
||||
const token = match[0];
|
||||
const key = `${match.index}-${token}`;
|
||||
|
||||
if (token.startsWith('#')) {
|
||||
if (token.startsWith('[[') && token.endsWith(']]')) {
|
||||
const link = parseWikiLink(token.slice(2, -2));
|
||||
const onClick = options?.onWikiLinkClick;
|
||||
elements.push(
|
||||
onClick ? (
|
||||
<span
|
||||
key={key}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(link.title, link.blockId);
|
||||
}}
|
||||
className="text-status-info underline decoration-status-info/40 underline-offset-2 hover:decoration-status-info cursor-pointer"
|
||||
title={link.blockId ? `Zum Punkt in „${link.title}“` : `Notiz öffnen: ${link.title}`}
|
||||
>
|
||||
{wikiLinkText(link)}
|
||||
</span>
|
||||
) : (
|
||||
<span key={key} className="text-status-info">
|
||||
{wikiLinkText(link)}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
} else if (token.startsWith('#')) {
|
||||
const prevChar = match.index > 0 ? text[match.index - 1] : '';
|
||||
if (prevChar && !/[\s(]/.test(prevChar)) {
|
||||
// e.g. an URL fragment like https://x/#anchor: keep as plain text
|
||||
@@ -59,7 +86,7 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
|
||||
}
|
||||
} else if (token.startsWith('`') && token.endsWith('`')) {
|
||||
elements.push(
|
||||
<code key={key} className="px-1.5 py-0.5 rounded bg-panel-elevated text-accent font-mono text-[0.9em] border border-border/50">
|
||||
<code key={key} className="px-1.5 py-0.5 rounded bg-main/[0.12] text-main font-mono text-[0.9em]">
|
||||
{token.slice(1, -1)}
|
||||
</code>
|
||||
);
|
||||
@@ -69,7 +96,7 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
|
||||
) {
|
||||
elements.push(
|
||||
<strong key={key} className="font-semibold text-main">
|
||||
{token.slice(2, -2)}
|
||||
{renderInlineTokens(token.slice(2, -2), options)}
|
||||
</strong>
|
||||
);
|
||||
} else if (
|
||||
@@ -78,13 +105,13 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
|
||||
) {
|
||||
elements.push(
|
||||
<em key={key} className="italic text-main/90">
|
||||
{token.slice(1, -1)}
|
||||
{renderInlineTokens(token.slice(1, -1), options)}
|
||||
</em>
|
||||
);
|
||||
} else if (token.startsWith('~~') && token.endsWith('~~')) {
|
||||
elements.push(
|
||||
<del key={key} className="line-through text-muted/80">
|
||||
{token.slice(2, -2)}
|
||||
{renderInlineTokens(token.slice(2, -2), options)}
|
||||
</del>
|
||||
);
|
||||
} else if (token.startsWith('[') && match[2] && match[3] && safeHref(match[3])) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BlockType } from './types';
|
||||
|
||||
// Turns pasted (Markdown) text into outline items: headings, todos, bullets, numbered items, dividers,
|
||||
// fenced code and paragraphs. A blank line ends a paragraph; indentation becomes the nesting level.
|
||||
// Content below a heading is nested under it (H2 below H1 one level deeper), so sections fold and zoom.
|
||||
|
||||
export interface PastedItem {
|
||||
type: BlockType;
|
||||
@@ -31,6 +32,13 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] {
|
||||
let current: PastedItem | null = null;
|
||||
let inCode = false;
|
||||
let afterHeading = false;
|
||||
const headings: { rank: number; level: number }[] = []; // open headings, innermost last
|
||||
// A list right after a paragraph ("Folgende Punkte:") nests under it: level of that paragraph,
|
||||
// and the extra indent of the current list run
|
||||
let paragraphLevel: number | null = null;
|
||||
let listBoost = 0;
|
||||
let inList = false;
|
||||
const sectionLevel = () => (headings.length ? headings[headings.length - 1].level + 1 : 0);
|
||||
|
||||
const flush = () => {
|
||||
if (current) {
|
||||
@@ -52,13 +60,15 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] {
|
||||
if (rawLine.trim().startsWith('```')) {
|
||||
inCode = true;
|
||||
flush();
|
||||
current = { type: 'text', content: rawLine, indent: 0 };
|
||||
current = { type: 'text', content: rawLine, indent: sectionLevel() };
|
||||
afterHeading = false;
|
||||
paragraphLevel = null;
|
||||
inList = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { width, rest } = leadingWidth(rawLine);
|
||||
const indent = levelOf(width);
|
||||
const indent = sectionLevel() + levelOf(width);
|
||||
const line = rest.trimEnd();
|
||||
|
||||
// A blank line ends the paragraph or list item
|
||||
@@ -71,13 +81,22 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] {
|
||||
if (heading) {
|
||||
flush();
|
||||
afterHeading = true;
|
||||
items.push({ type: (['h1', 'h2', 'h3'] as const)[heading[1].length - 1], content: heading[2], indent });
|
||||
paragraphLevel = null;
|
||||
inList = false;
|
||||
const rank = heading[1].length;
|
||||
while (headings.length && headings[headings.length - 1].rank >= rank) headings.pop();
|
||||
const level = sectionLevel();
|
||||
headings.push({ rank, level });
|
||||
items.push({ type: (['h1', 'h2', 'h3'] as const)[rank - 1], content: heading[2], indent: level });
|
||||
continue;
|
||||
}
|
||||
if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(line)) {
|
||||
flush();
|
||||
afterHeading = false;
|
||||
items.push({ type: 'divider', content: '---', indent });
|
||||
headings.length = 0; // a divider closes the sections
|
||||
paragraphLevel = null;
|
||||
inList = false;
|
||||
items.push({ type: 'divider', content: '---', indent: 0 });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -87,11 +106,16 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] {
|
||||
if (todo || bullet || numbered) {
|
||||
flush();
|
||||
afterHeading = false;
|
||||
if (!inList) {
|
||||
inList = true;
|
||||
listBoost = paragraphLevel !== null && indent <= paragraphLevel ? paragraphLevel + 1 - indent : 0;
|
||||
}
|
||||
const level = indent + listBoost;
|
||||
current = todo
|
||||
? { type: 'todo', checked: todo[1].toLowerCase() === 'x', content: todo[2], indent }
|
||||
? { type: 'todo', checked: todo[1].toLowerCase() === 'x', content: todo[2], indent: level }
|
||||
: numbered
|
||||
? { type: 'numbered', content: numbered[1], indent }
|
||||
: { type: 'text', content: bullet![1], indent };
|
||||
? { type: 'numbered', content: numbered[1], indent: level }
|
||||
: { type: 'text', content: bullet![1], indent: level };
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -101,6 +125,8 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] {
|
||||
} else {
|
||||
flush();
|
||||
current = { type: 'text', content: line, indent };
|
||||
paragraphLevel = indent;
|
||||
inList = false;
|
||||
}
|
||||
afterHeading = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { api } from './api';
|
||||
|
||||
// Web push in this browser: the service worker shows the notifications, the server sends them over the
|
||||
// browser's push service (VAPID). iPhones and iPads need MindSift added to the home screen first.
|
||||
|
||||
export function pushSupported(): boolean {
|
||||
return typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
|
||||
}
|
||||
|
||||
/** iOS/iPadOS outside the installed home screen app: push is not offered there */
|
||||
export function needsHomeScreenInstall(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const standalone =
|
||||
window.matchMedia?.('(display-mode: standalone)').matches || (navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||
return ios && !standalone;
|
||||
}
|
||||
|
||||
export function deviceName(ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''): string {
|
||||
const browser = /Edg\//.test(ua) ? 'Edge' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
|
||||
const os = /Android/.test(ua) ? 'Android' : /iPhone|iPad|iPod/.test(ua) ? 'iOS' : /Windows/.test(ua) ? 'Windows' : /Mac OS X/.test(ua) ? 'macOS' : /Linux/.test(ua) ? 'Linux' : '';
|
||||
return os ? `${browser} auf ${os}` : browser;
|
||||
}
|
||||
|
||||
export function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||
const padded = (base64 + '='.repeat((4 - (base64.length % 4)) % 4)).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(padded);
|
||||
const out = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function registration(): Promise<ServiceWorkerRegistration> {
|
||||
return navigator.serviceWorker.ready;
|
||||
}
|
||||
|
||||
/** The subscription of this browser, if push is switched on here. */
|
||||
export async function currentPushSubscription(): Promise<PushSubscription | null> {
|
||||
if (!pushSupported()) return null;
|
||||
try {
|
||||
return await (await registration()).pushManager.getSubscription();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function enablePush(): Promise<void> {
|
||||
if (!pushSupported()) throw new Error('Dieser Browser unterstützt keine Push-Benachrichtigungen');
|
||||
if (needsHomeScreenInstall()) throw new Error('Auf iPhone und iPad zuerst MindSift „Zum Home-Bildschirm“ hinzufügen und von dort öffnen');
|
||||
const key = await api.getPushKey();
|
||||
if (!key.enabled || !key.publicKey) throw new Error('Push ist auf diesem Server nicht verfügbar');
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') throw new Error('Benachrichtigungen wurden im Browser nicht erlaubt');
|
||||
const reg = await registration();
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
if (!sub) {
|
||||
try {
|
||||
sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(key.publicKey) as BufferSource,
|
||||
});
|
||||
} catch {
|
||||
// e.g. "Registration failed – permission denied": blocked for the site or no push service reachable
|
||||
throw new Error('Der Browser hat die Anmeldung beim Push-Dienst abgelehnt (Berechtigung blockiert oder Push-Dienst nicht erreichbar)');
|
||||
}
|
||||
}
|
||||
await api.subscribePush(sub.toJSON(), deviceName());
|
||||
}
|
||||
|
||||
export async function disablePush(): Promise<void> {
|
||||
const sub = await currentPushSubscription();
|
||||
if (!sub) return;
|
||||
const endpoint = sub.endpoint;
|
||||
await sub.unsubscribe().catch(() => false);
|
||||
await api.unsubscribePush(endpoint).catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
export interface SharedData {
|
||||
id?: number;
|
||||
title?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
fileName?: string;
|
||||
hasMedia?: boolean;
|
||||
createdAt?: number;
|
||||
}
|
||||
|
||||
const SHARE_DB_NAME = 'mindsift-share-target';
|
||||
const SHARE_STORE_NAME = 'shared_items';
|
||||
|
||||
/**
|
||||
* Combines title, text, url and media note into a single thought string.
|
||||
* Ensures URLs are not duplicated if already present in the shared text or title.
|
||||
*/
|
||||
export function formatSharedThought(share: SharedData): string {
|
||||
const title = (share.title || '').trim();
|
||||
const text = (share.text || '').trim();
|
||||
const url = (share.url || '').trim();
|
||||
const mediaNote = '(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)';
|
||||
|
||||
let content = '';
|
||||
|
||||
// Check if url is already contained in text or title
|
||||
const urlInText = url !== '' && text.includes(url);
|
||||
const urlInTitle = url !== '' && title.includes(url);
|
||||
|
||||
// Combine title and text
|
||||
if (title && text) {
|
||||
if (text === title) {
|
||||
content = text;
|
||||
} else if (text.startsWith(title)) {
|
||||
content = text;
|
||||
} else {
|
||||
content = `${title}\n${text}`;
|
||||
}
|
||||
} else if (title) {
|
||||
content = title;
|
||||
} else if (text) {
|
||||
content = text;
|
||||
}
|
||||
|
||||
// Append URL if not already included in text or title
|
||||
if (url && !urlInText && !urlInTitle) {
|
||||
if (content) {
|
||||
content = `${content}\n${url}`;
|
||||
} else {
|
||||
content = url;
|
||||
}
|
||||
}
|
||||
|
||||
// Media note handling
|
||||
if (share.hasMedia || share.fileName) {
|
||||
if (content) {
|
||||
content = `${content}\n${mediaNote}`;
|
||||
} else if (share.fileName) {
|
||||
content = `[${share.fileName}] ${mediaNote}`;
|
||||
} else {
|
||||
content = mediaNote;
|
||||
}
|
||||
}
|
||||
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves and removes all pending shared items stored in IndexedDB by the Service Worker.
|
||||
*/
|
||||
export async function consumePendingShares(): Promise<SharedData[]> {
|
||||
if (typeof indexedDB === 'undefined') return [];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const req = indexedDB.open(SHARE_DB_NAME, 1);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) {
|
||||
db.createObjectStore(SHARE_STORE_NAME, { autoIncrement: true, keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) {
|
||||
db.close();
|
||||
return resolve([]);
|
||||
}
|
||||
|
||||
const tx = db.transaction(SHARE_STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(SHARE_STORE_NAME);
|
||||
const getAllReq = store.getAll();
|
||||
|
||||
getAllReq.onsuccess = () => {
|
||||
const items: SharedData[] = getAllReq.result || [];
|
||||
store.clear();
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve(items);
|
||||
};
|
||||
tx.onerror = () => {
|
||||
db.close();
|
||||
resolve(items);
|
||||
};
|
||||
};
|
||||
|
||||
getAllReq.onerror = () => {
|
||||
db.close();
|
||||
resolve([]);
|
||||
};
|
||||
};
|
||||
req.onerror = () => resolve([]);
|
||||
} catch {
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -157,8 +157,8 @@ function applyToDocument(themeId: ThemeId, customCss: string | null): void {
|
||||
syncThemeColorMeta();
|
||||
}
|
||||
|
||||
// The status bar (theme-color) starts blue for the splash screen; once the app runs it follows the
|
||||
// active theme's background so it blends with the header.
|
||||
// The status bar (theme-color) starts black (like the Android navigation bar of the installed app,
|
||||
// which only takes the manifest colour); once the app runs it follows the theme's background.
|
||||
function syncThemeColorMeta(): void {
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (!meta || typeof requestAnimationFrame === 'undefined') return;
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface AuthStatus {
|
||||
hasImprintContent?: boolean;
|
||||
privacyUrl?: string;
|
||||
hasPrivacyContent?: boolean;
|
||||
customLinkLabel?: string;
|
||||
customLinkUrl?: string;
|
||||
};
|
||||
// Voice input: own Whisper server, the browser's recognition, or switched off (STT_* environment)
|
||||
voice?: { mode: VoiceMode; maxSeconds: number };
|
||||
@@ -50,6 +52,7 @@ export interface NotificationChannelRouting {
|
||||
email: boolean;
|
||||
discord: boolean;
|
||||
webhook: boolean;
|
||||
push: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationEvents {
|
||||
@@ -65,6 +68,15 @@ export interface DailySummarySettings {
|
||||
email: boolean;
|
||||
discord: boolean;
|
||||
webhook: boolean;
|
||||
push: boolean;
|
||||
}
|
||||
|
||||
/** A browser or installed app that receives web push */
|
||||
export interface PushDevice {
|
||||
id: number;
|
||||
endpoint: string;
|
||||
device: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface NotificationSettings {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Wiki links: [[Titel]], [[Titel|Anzeigetext]] and links to a single bullet [[Titel#^blockId|Anzeigetext]].
|
||||
// A missing bullet just opens the note (links are not updated when a bullet moves to another note).
|
||||
|
||||
export interface WikiLink {
|
||||
title: string;
|
||||
blockId?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Parses the inside of [[…]]. */
|
||||
export function parseWikiLink(inner: string): WikiLink {
|
||||
const [target, ...rest] = inner.split('|');
|
||||
const label = rest.join('|').trim() || undefined;
|
||||
const hash = target.indexOf('#');
|
||||
const title = (hash === -1 ? target : target.slice(0, hash)).trim();
|
||||
const anchor = hash === -1 ? '' : target.slice(hash + 1).trim();
|
||||
return { title, blockId: anchor.startsWith('^') ? anchor.slice(1) || undefined : undefined, label };
|
||||
}
|
||||
|
||||
/** Text shown for a link: its label, else the note title (with » for a bullet). */
|
||||
export function wikiLinkText(link: WikiLink): string {
|
||||
if (link.label) return link.label;
|
||||
return link.blockId ? `${link.title} »` : link.title;
|
||||
}
|
||||
|
||||
/** Link to one bullet; the label is a short excerpt of the bullet so the reader knows what it points to. */
|
||||
export function buildBlockLink(title: string, blockId: string, blockContent: string): string {
|
||||
const clean = (s: string) => s.replace(/[[\]|]/g, '').replace(/\s+/g, ' ').trim();
|
||||
let excerpt = clean(blockContent);
|
||||
if (excerpt.length > 40) excerpt = `${excerpt.slice(0, 39).trimEnd()}…`;
|
||||
const label = excerpt ? `${clean(title)} › ${excerpt}` : clean(title);
|
||||
return `[[${clean(title)}#^${blockId}|${label}]]`;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ module github.com/sandrodach/mindsift
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/go-chi/chi/v5 v5.2.0
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
|
||||
@@ -6,6 +8,7 @@ github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -22,15 +25,75 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
|
||||
@@ -143,6 +143,9 @@ func (s *Server) startAlarmTicker() {
|
||||
if set.Events.Due.Webhook {
|
||||
channels = append(channels, "webhook")
|
||||
}
|
||||
if set.Events.Due.Push {
|
||||
channels = append(channels, "push")
|
||||
}
|
||||
if a.AlarmEmail == "" {
|
||||
a.AlarmEmail = a.UserEmail
|
||||
}
|
||||
@@ -166,6 +169,10 @@ func (s *Server) startAlarmTicker() {
|
||||
sentAny = true
|
||||
}
|
||||
}
|
||||
case "push":
|
||||
if err := s.sendPush(a.UserID, pushMessage{Title: "⏰ Aufgabe fällig", Body: a.Title + " – " + due, URL: "/", Tag: "task-" + a.TaskID}); err == nil {
|
||||
sentAny = true
|
||||
}
|
||||
case "webhook":
|
||||
if a.CustomWebhookURL != "" {
|
||||
if err := s.sendCustomWebhookNotification(a.CustomWebhookURL, "task_due", a.Title, desc, a.DueDate, a.Priority); err == nil {
|
||||
|
||||
@@ -52,6 +52,8 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"hasImprintContent": s.cfg.Legal.ImprintContent != "",
|
||||
"privacyUrl": s.cfg.Legal.PrivacyURL,
|
||||
"hasPrivacyContent": s.cfg.Legal.PrivacyContent != "",
|
||||
"customLinkLabel": s.cfg.Legal.CustomLinkLabel,
|
||||
"customLinkUrl": s.cfg.Legal.CustomLinkURL,
|
||||
},
|
||||
}
|
||||
// Countdown to the next demo reset; seconds as well, so client clocks do not matter
|
||||
|
||||
+30
-3
@@ -190,7 +190,7 @@ func (s *Server) flushNotifications(now time.Time) {
|
||||
|
||||
// channelItems collects what goes out on each channel.
|
||||
type channelItems struct {
|
||||
email, discord, webhook []notifyItem
|
||||
email, discord, webhook, push []notifyItem
|
||||
}
|
||||
|
||||
func (c *channelItems) add(ch domain.ChannelChoice, items ...notifyItem) {
|
||||
@@ -203,6 +203,9 @@ func (c *channelItems) add(ch domain.ChannelChoice, items ...notifyItem) {
|
||||
if ch.Webhook {
|
||||
c.webhook = append(c.webhook, items...)
|
||||
}
|
||||
if ch.Push {
|
||||
c.push = append(c.push, items...)
|
||||
}
|
||||
}
|
||||
|
||||
// deliver sends the items to the user's mail address, Discord webhook and/or own webhook.
|
||||
@@ -236,6 +239,11 @@ func (s *Server) deliver(userID, heading string, out channelItems) error {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if len(out.push) > 0 {
|
||||
if err := s.sendPush(userID, pushDigest(heading, out.push, s.notifyLink())); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
@@ -254,6 +262,25 @@ func (s *Server) sendNotifyDiscord(webhookURL, heading string, items []notifyIte
|
||||
return s.sendDiscordDigest(webhookURL, heading, items)
|
||||
}
|
||||
|
||||
// pushDigest: one notification per bundle; a single event is its own title
|
||||
func pushDigest(heading string, items []notifyItem, link string) pushMessage {
|
||||
if link == "" {
|
||||
link = "/"
|
||||
}
|
||||
if len(items) == 1 {
|
||||
return pushMessage{Title: "MindSift", Body: items[0].text, URL: link, Tag: "digest"}
|
||||
}
|
||||
lines := make([]string, 0, 5)
|
||||
for i, it := range items {
|
||||
if i == 4 {
|
||||
lines = append(lines, fmt.Sprintf("… und %d weitere", len(items)-4))
|
||||
break
|
||||
}
|
||||
lines = append(lines, "• "+it.text)
|
||||
}
|
||||
return pushMessage{Title: "MindSift: " + heading, Body: strings.Join(lines, "\n"), URL: link, Tag: "digest"}
|
||||
}
|
||||
|
||||
func (s *Server) sendNotifyWebhook(webhookURL, heading string, items []notifyItem) error {
|
||||
if s.notifyWebhookHook != nil {
|
||||
return s.notifyWebhookHook(webhookURL, heading, items)
|
||||
@@ -377,7 +404,7 @@ func (s *Server) sendDailySummaries(now time.Time) {
|
||||
for _, uid := range s.store.ActiveUserIDs() {
|
||||
set := s.store.NotificationSettingsOf(uid)
|
||||
ds := set.DailySummary
|
||||
if set.Paused || !ds.Enabled || (!ds.Email && !ds.Discord && !ds.Webhook) {
|
||||
if set.Paused || !ds.Enabled || (!ds.Email && !ds.Discord && !ds.Webhook && !ds.Push) {
|
||||
continue
|
||||
}
|
||||
at, err := time.ParseInLocation("2006-01-02 15:04", day+" "+ds.Time, now.Location())
|
||||
@@ -389,7 +416,7 @@ func (s *Server) sendDailySummaries(now time.Time) {
|
||||
continue
|
||||
}
|
||||
var out channelItems
|
||||
out.add(domain.ChannelChoice{Email: ds.Email, Discord: ds.Discord, Webhook: ds.Webhook}, items...)
|
||||
out.add(domain.ChannelChoice{Email: ds.Email, Discord: ds.Discord, Webhook: ds.Webhook, Push: ds.Push}, items...)
|
||||
if err := s.deliver(uid, "Deine Aufgaben für heute", out); err != nil {
|
||||
slog.Warn("Tageszusammenfassung konnte nicht verschickt werden", "user_id", uid, "error", err)
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ func TestOwnChangesPauseAndChannels(t *testing.T) {
|
||||
|
||||
// Settings round trip over the profile API
|
||||
rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", myToken, `{"notificationSettings":{"paused":false,"events":{"shared":{"email":false,"discord":true}},"dailySummary":{"enabled":true,"time":"6:5"}}}`, "")
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"time":"07:00"`) || !strings.Contains(rec.Body.String(), `"shared":{"email":false,"discord":true,"webhook":false}`) {
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"time":"07:00"`) || !strings.Contains(rec.Body.String(), `"shared":{"email":false,"discord":true,"webhook":false,"push":false}`) {
|
||||
t.Fatalf("settings: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
)
|
||||
|
||||
// Web push: browsers and installed apps (Android, iOS from 16.4 as home screen app, desktop) subscribe
|
||||
// with the server's VAPID key; notifications reach them even when MindSift is closed. Push is one more
|
||||
// channel next to e-mail, Discord and the own webhook (notification settings). The VAPID key pair comes
|
||||
// from VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY or is generated once and kept in the database.
|
||||
|
||||
type pushKeys struct {
|
||||
once sync.Once
|
||||
public, priv string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *Server) vapidKeys() (string, string, error) {
|
||||
s.push.once.Do(func() {
|
||||
if s.cfg.VAPIDPublicKey != "" && s.cfg.VAPIDPrivateKey != "" {
|
||||
s.push.public, s.push.priv = s.cfg.VAPIDPublicKey, s.cfg.VAPIDPrivateKey
|
||||
return
|
||||
}
|
||||
s.push.public, s.push.priv, s.push.err = s.store.PushKeys(func() (string, string, error) {
|
||||
priv, pub, err := webpush.GenerateVAPIDKeys()
|
||||
return pub, priv, err
|
||||
})
|
||||
})
|
||||
return s.push.public, s.push.priv, s.push.err
|
||||
}
|
||||
|
||||
// vapidSubject: contact for the push services (VAPID_SUBJECT, else the https base URL)
|
||||
func (s *Server) vapidSubject() string {
|
||||
if s.cfg.VAPIDSubject != "" {
|
||||
return s.cfg.VAPIDSubject
|
||||
}
|
||||
if s.cfg.BaseURLSet && strings.HasPrefix(s.cfg.BaseURL, "https://") {
|
||||
return strings.TrimRight(s.cfg.BaseURL, "/")
|
||||
}
|
||||
return "mailto:push@mindsift.invalid"
|
||||
}
|
||||
|
||||
type pushMessage struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
URL string `json:"url"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
}
|
||||
|
||||
// sendPush delivers a message to every device of the user; devices the push service no longer knows
|
||||
// are forgotten. Error when no device could be reached.
|
||||
func (s *Server) sendPush(userID string, msg pushMessage) error {
|
||||
if s.notifyPushHook != nil {
|
||||
return s.notifyPushHook(userID, msg)
|
||||
}
|
||||
if s.cfg.DemoMode {
|
||||
return errDemoOutgoing
|
||||
}
|
||||
subs, err := s.store.PushSubscriptions(userID)
|
||||
if err != nil || len(subs) == 0 {
|
||||
return err
|
||||
}
|
||||
pub, priv, err := s.vapidKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, _ := json.Marshal(msg)
|
||||
client := s.pushHTTPClient
|
||||
if client == nil {
|
||||
client = guardedClient(s.allowPrivateWebhooks())
|
||||
}
|
||||
var lastErr error
|
||||
sent := 0
|
||||
for _, sub := range subs {
|
||||
resp, err := webpush.SendNotification(payload, &webpush.Subscription{
|
||||
Endpoint: sub.Endpoint,
|
||||
Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth},
|
||||
}, &webpush.Options{
|
||||
HTTPClient: client,
|
||||
Subscriber: s.vapidSubject(),
|
||||
VAPIDPublicKey: pub,
|
||||
VAPIDPrivateKey: priv,
|
||||
TTL: 24 * 3600,
|
||||
Urgency: webpush.UrgencyHigh,
|
||||
})
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
|
||||
s.store.ForgetPushEndpoint(sub.Endpoint)
|
||||
case resp.StatusCode >= 400:
|
||||
lastErr = fmt.Errorf("Push-Dienst antwortet mit HTTP %d", resp.StatusCode)
|
||||
default:
|
||||
sent++
|
||||
}
|
||||
}
|
||||
if sent == 0 && lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg.DemoMode {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"enabled": false})
|
||||
return
|
||||
}
|
||||
pub, _, err := s.vapidKeys()
|
||||
if err != nil {
|
||||
slog.Error("VAPID-Schlüssel nicht verfügbar", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "Push ist auf diesem Server nicht verfügbar")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"enabled": true, "publicKey": pub})
|
||||
}
|
||||
|
||||
func (s *Server) handlePushDevices(w http.ResponseWriter, r *http.Request) {
|
||||
subs, err := s.store.PushSubscriptions(auth.GetUserID(r.Context()))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Geräte konnten nicht geladen werden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"devices": subs})
|
||||
}
|
||||
|
||||
func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg.DemoMode {
|
||||
writeError(w, http.StatusForbidden, "In der Demo deaktiviert")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Keys struct {
|
||||
P256dh string `json:"p256dh"`
|
||||
Auth string `json:"auth"`
|
||||
} `json:"keys"`
|
||||
Device string `json:"device"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||
return
|
||||
}
|
||||
if err := validPushEndpoint(req.Endpoint); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Keys.P256dh == "" || req.Keys.Auth == "" {
|
||||
writeError(w, http.StatusBadRequest, "Schlüssel des Geräts fehlen")
|
||||
return
|
||||
}
|
||||
device := strings.TrimSpace(req.Device)
|
||||
if len(device) > 120 {
|
||||
device = device[:120]
|
||||
}
|
||||
if err := s.store.SavePushSubscription(auth.GetUserID(r.Context()), req.Endpoint, req.Keys.P256dh, req.Keys.Auth, device); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handlePushUnsubscribe(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Endpoint == "" {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeletePushSubscription(auth.GetUserID(r.Context()), req.Endpoint); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht entfernt werden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handlePushTest(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
subs, _ := s.store.PushSubscriptions(userID)
|
||||
if len(subs) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "Auf keinem Gerät sind Push-Benachrichtigungen eingeschaltet")
|
||||
return
|
||||
}
|
||||
err := s.sendPush(userID, pushMessage{Title: "MindSift", Body: "Test: Push-Benachrichtigungen kommen an.", URL: "/", Tag: "test"})
|
||||
if err != nil {
|
||||
if errors.Is(err, errDemoOutgoing) {
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
slog.Warn("Test-Push fehlgeschlagen", "user_id", userID, "error", err)
|
||||
writeError(w, http.StatusBadGateway, "Push konnte nicht zugestellt werden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"devices": len(subs)})
|
||||
}
|
||||
|
||||
// validPushEndpoint: the push services of the browsers are https URLs on the public internet.
|
||||
func validPushEndpoint(endpoint string) error {
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || len(endpoint) > 2048 {
|
||||
return errors.New("Ungültige Push-Adresse")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/hkdf"
|
||||
)
|
||||
|
||||
// A push sent by the server can be decrypted like a browser does (RFC 8291, aes128gcm) and carries
|
||||
// a VAPID authorization; a device the push service no longer knows (410) is forgotten.
|
||||
func TestPushPayloadDecryptsLikeABrowser(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
me, _ := newTestUser(t, store, "geraet@example.com")
|
||||
|
||||
// The "browser": its key pair and auth secret
|
||||
recv, _ := ecdh.P256().GenerateKey(rand.Reader)
|
||||
authSecret := make([]byte, 16)
|
||||
_, _ = rand.Read(authSecret)
|
||||
b64 := base64.RawURLEncoding
|
||||
|
||||
var body []byte
|
||||
var authz string
|
||||
status := http.StatusCreated
|
||||
push := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ = io.ReadAll(r.Body)
|
||||
authz = r.Header.Get("Authorization")
|
||||
w.WriteHeader(status)
|
||||
}))
|
||||
defer push.Close()
|
||||
srv.pushHTTPClient = push.Client()
|
||||
|
||||
endpoint := push.URL + "/send/geraet-1"
|
||||
if err := store.SavePushSubscription(me.ID, endpoint, b64.EncodeToString(recv.PublicKey().Bytes()), b64.EncodeToString(authSecret), "Test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := srv.sendPush(me.ID, pushMessage{Title: "MindSift", Body: "Steuer fällig", URL: "/"}); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(authz, "vapid t=") || !strings.Contains(authz, ", k=") {
|
||||
t.Fatalf("missing VAPID authorization: %q", authz)
|
||||
}
|
||||
|
||||
// aes128gcm header: salt(16) | record size(4) | key id length(1) | sender public key
|
||||
salt, idLen := body[:16], int(body[20])
|
||||
senderPub, cipherText := body[21:21+idLen], body[21+idLen:]
|
||||
sender, err := ecdh.P256().NewPublicKey(senderPub)
|
||||
if err != nil {
|
||||
t.Fatalf("sender key: %v", err)
|
||||
}
|
||||
shared, _ := recv.ECDH(sender)
|
||||
info := append(append([]byte("WebPush: info\x00"), recv.PublicKey().Bytes()...), senderPub...)
|
||||
ikm := make([]byte, 32)
|
||||
_, _ = io.ReadFull(hkdf.New(sha256.New, shared, authSecret, info), ikm)
|
||||
cek := make([]byte, 16)
|
||||
nonce := make([]byte, 12)
|
||||
_, _ = io.ReadFull(hkdf.New(sha256.New, ikm, salt, []byte("Content-Encoding: aes128gcm\x00")), cek)
|
||||
_, _ = io.ReadFull(hkdf.New(sha256.New, ikm, salt, []byte("Content-Encoding: nonce\x00")), nonce)
|
||||
block, _ := aes.NewCipher(cek)
|
||||
gcm, _ := cipher.NewGCM(block)
|
||||
plain, err := gcm.Open(nil, nonce, cipherText, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
plain = plain[:strings.LastIndexByte(string(plain), 2)] // padding delimiter
|
||||
var got pushMessage
|
||||
if err := json.Unmarshal(plain, &got); err != nil || got.Body != "Steuer fällig" {
|
||||
t.Fatalf("payload %q: %v", plain, err)
|
||||
}
|
||||
|
||||
// The push service says the device is gone: it is removed
|
||||
status = http.StatusGone
|
||||
_ = srv.sendPush(me.ID, pushMessage{Title: "x"})
|
||||
if subs, _ := store.PushSubscriptions(me.ID); len(subs) != 0 {
|
||||
t.Fatalf("gone device must be forgotten, still %d", len(subs))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
func TestPushSubscribeKeyAndChannel(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
srv.cfg.NotifyDigestMinutes = 5
|
||||
router := srv.Router()
|
||||
me, token := newTestUser(t, store, "push@example.com")
|
||||
|
||||
// The key is generated once and stays the same
|
||||
var k1, k2 struct {
|
||||
Enabled bool
|
||||
PublicKey string
|
||||
}
|
||||
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/push/key", token, "", "").Body.Bytes(), &k1)
|
||||
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/push/key", token, "", "").Body.Bytes(), &k2)
|
||||
if !k1.Enabled || len(k1.PublicKey) < 80 || k1.PublicKey != k2.PublicKey {
|
||||
t.Fatalf("key: %+v / %+v", k1, k2)
|
||||
}
|
||||
|
||||
if rec := sessionCall(router, "POST", "/api/v1/push/subscribe", token, `{"endpoint":"http://intern/x","keys":{"p256dh":"a","auth":"b"}}`, ""); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("http endpoint must be refused, got %d", rec.Code)
|
||||
}
|
||||
body := `{"endpoint":"https://push.example.com/abc","keys":{"p256dh":"BAAA","auth":"CCCC"},"device":"Firefox auf Linux"}`
|
||||
if rec := sessionCall(router, "POST", "/api/v1/push/subscribe", token, body, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("subscribe: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec := sessionCall(router, "GET", "/api/v1/push/devices", token, "", "")
|
||||
if !strings.Contains(rec.Body.String(), "Firefox auf Linux") || strings.Contains(rec.Body.String(), "CCCC") {
|
||||
t.Fatalf("devices must list the device without its keys: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Push as a channel for watched tasks
|
||||
var pushed []pushMessage
|
||||
srv.notifyPushHook = func(userID string, msg pushMessage) error {
|
||||
if userID == me.ID {
|
||||
pushed = append(pushed, msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
task, _ := store.CreateThoughtWithDetails(me.ID, "Reifen wechseln", "normal", "manual", "", "", "todo", "")
|
||||
_ = store.WatchTask(task.ID, me.ID, true)
|
||||
set := store.NotificationSettingsOf(me.ID)
|
||||
set.IncludeOwnWatched = true
|
||||
set.Events.Watched = domain.ChannelChoice{Push: true}
|
||||
_ = store.SetNotificationSettings(me.ID, set)
|
||||
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, token, `{"priority":"high"}`, "")
|
||||
srv.flushNotifications(time.Now().Add(10 * time.Minute))
|
||||
if len(pushed) != 1 || !strings.Contains(pushed[0].Body, "Reifen wechseln") {
|
||||
t.Fatalf("expected one push, got %+v", pushed)
|
||||
}
|
||||
|
||||
if rec := sessionCall(router, "POST", "/api/v1/push/unsubscribe", token, `{"endpoint":"https://push.example.com/abc"}`, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("unsubscribe: %d", rec.Code)
|
||||
}
|
||||
if rec := sessionCall(router, "POST", "/api/v1/push/test", token, "", ""); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("test without devices: want 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushDigestText(t *testing.T) {
|
||||
one := pushDigest("Neuigkeiten", []notifyItem{{text: "Anna hat „Einkauf“ erledigt"}}, "")
|
||||
if one.Body != "Anna hat „Einkauf“ erledigt" || one.URL != "/" {
|
||||
t.Fatalf("single: %+v", one)
|
||||
}
|
||||
many := pushDigest("Neuigkeiten", []notifyItem{{text: "a"}, {text: "b"}, {text: "c"}, {text: "d"}, {text: "e"}, {text: "f"}}, "https://x/")
|
||||
if many.Title != "MindSift: Neuigkeiten" || !strings.HasSuffix(many.Body, "… und 2 weitere") {
|
||||
t.Fatalf("many: %+v", many)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@ type Server struct {
|
||||
notifyEmailHook func(to, subject, text, html string) error
|
||||
notifyDiscordHook func(webhookURL, heading string, items []notifyItem) error
|
||||
notifyWebhookHook func(webhookURL, heading string, items []notifyItem) error
|
||||
notifyPushHook func(userID string, msg pushMessage) error
|
||||
pushHTTPClient *http.Client // tests: reach a local push service
|
||||
push pushKeys
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
|
||||
@@ -218,6 +221,11 @@ func (s *Server) Router() http.Handler {
|
||||
r.Put("/api/v1/workspace/tags/{tag}/note", s.handleUpdateTagPageNote)
|
||||
|
||||
r.Get("/api/v1/journal", s.handleGetJournal)
|
||||
r.Get("/api/v1/push/key", s.handlePushKey)
|
||||
r.Get("/api/v1/push/devices", s.handlePushDevices)
|
||||
r.Post("/api/v1/push/subscribe", s.handlePushSubscribe)
|
||||
r.Post("/api/v1/push/unsubscribe", s.handlePushUnsubscribe)
|
||||
r.Post("/api/v1/push/test", s.handlePushTest)
|
||||
r.Post("/api/v1/journal/{date}", s.handleOpenJournalDay)
|
||||
r.Get("/api/v1/documents", s.handleGetDocuments)
|
||||
r.Post("/api/v1/documents", s.handleCreateDocument)
|
||||
@@ -308,6 +316,11 @@ func (s *Server) Router() http.Handler {
|
||||
})
|
||||
})
|
||||
|
||||
// Web Share Target fallback when service worker is not active yet: redirect to /
|
||||
r.Post("/share-target", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
// Embedded Frontend SPA Handler
|
||||
if s.staticFS != nil {
|
||||
fileServer := http.FileServer(http.FS(s.staticFS))
|
||||
|
||||
@@ -1388,7 +1388,12 @@ func TestLegalTextsArePublic(t *testing.T) {
|
||||
}
|
||||
defer store.Close()
|
||||
cfg := &config.Config{Port: "8080", DatabasePath: dbPath, JWTSecret: "test-jwt-secret-key",
|
||||
Legal: config.LegalTexts{ImprintContent: "# Impressum\nMax Muster", PrivacyURL: "https://example.org/datenschutz"}}
|
||||
Legal: config.LegalTexts{
|
||||
ImprintContent: "# Impressum\nMax Muster",
|
||||
PrivacyURL: "https://example.org/datenschutz",
|
||||
CustomLinkLabel: "Support",
|
||||
CustomLinkURL: "https://support.example.org",
|
||||
}}
|
||||
router := NewServer(cfg, store, ws.NewHub(), nil).Router()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -1399,10 +1404,12 @@ func TestLegalTextsArePublic(t *testing.T) {
|
||||
HasImprintContent bool `json:"hasImprintContent"`
|
||||
PrivacyURL string `json:"privacyUrl"`
|
||||
HasPrivacyContent bool `json:"hasPrivacyContent"`
|
||||
CustomLinkLabel string `json:"customLinkLabel"`
|
||||
CustomLinkURL string `json:"customLinkUrl"`
|
||||
} `json:"legal"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &st)
|
||||
if !st.Legal.HasImprintContent || st.Legal.HasPrivacyContent || st.Legal.PrivacyURL != "https://example.org/datenschutz" {
|
||||
if !st.Legal.HasImprintContent || st.Legal.HasPrivacyContent || st.Legal.PrivacyURL != "https://example.org/datenschutz" || st.Legal.CustomLinkLabel != "Support" || st.Legal.CustomLinkURL != "https://support.example.org" {
|
||||
t.Fatalf("unexpected legal status: %+v", st.Legal)
|
||||
}
|
||||
|
||||
@@ -1526,6 +1533,24 @@ func TestPasswordResetFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareTargetFallbackRedirects(t *testing.T) {
|
||||
store, err := storage.NewStore(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
router := NewServer(&config.Config{Port: "8080", DatabasePath: ":memory:", JWTSecret: "test-jwt-secret-key"}, store, ws.NewHub(), nil).Router()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/share-target", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected 303 See Other, got %d", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/" {
|
||||
t.Fatalf("expected Location '/', got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
// testSession starts a server-side session, as a login would, for tokens made directly in tests.
|
||||
func testSession(t *testing.T, store *storage.Store, userID string) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -54,6 +54,11 @@ type Config struct {
|
||||
|
||||
// NotifyDigestMinutes: notifications (except task alarms) are bundled over this many minutes
|
||||
NotifyDigestMinutes int
|
||||
|
||||
// Web push (VAPID_*): keys are generated and stored in the database when not set
|
||||
VAPIDPublicKey string
|
||||
VAPIDPrivateKey string
|
||||
VAPIDSubject string
|
||||
}
|
||||
|
||||
// MediaConfig limits uploads of images and audio recordings in notes. Sizes in MB; QuotaMB 0 = no limit
|
||||
@@ -94,8 +99,9 @@ func (s SpeechConfig) Mode(demo bool) string {
|
||||
}
|
||||
|
||||
type LegalTexts struct {
|
||||
ImprintURL, ImprintContent string
|
||||
PrivacyURL, PrivacyContent string
|
||||
ImprintURL, ImprintContent string
|
||||
PrivacyURL, PrivacyContent string
|
||||
CustomLinkLabel, CustomLinkURL string
|
||||
}
|
||||
|
||||
// Publicly known JWT secrets (old code default and the placeholders from .env.example, compose and
|
||||
@@ -171,6 +177,20 @@ func legalURL(name string) string {
|
||||
return v
|
||||
}
|
||||
|
||||
// legalCustomLink accepts only https links for the custom footer link.
|
||||
func legalCustomLink(labelVar, urlVar string) (string, string) {
|
||||
u := strings.TrimSpace(os.Getenv(urlVar))
|
||||
if u == "" {
|
||||
return "", ""
|
||||
}
|
||||
if !strings.HasPrefix(u, "https://") {
|
||||
slog.Warn("Custom-Link-URL ignoriert: nur https erlaubt", "variable", urlVar, "url", u)
|
||||
return "", ""
|
||||
}
|
||||
label := strings.TrimSpace(os.Getenv(labelVar))
|
||||
return label, u
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
port := getEnv("PORT", "8080")
|
||||
dataDir := getEnv("DATA_DIR", "./data")
|
||||
@@ -241,18 +261,26 @@ func Load() *Config {
|
||||
TimeoutSeconds: envInt("STT_TIMEOUT_SECONDS", 120, 5, 3600),
|
||||
},
|
||||
NotifyDigestMinutes: envInt("NOTIFY_DIGEST_MINUTES", 5, 1, 1440),
|
||||
VAPIDPublicKey: strings.TrimSpace(os.Getenv("VAPID_PUBLIC_KEY")),
|
||||
VAPIDPrivateKey: strings.TrimSpace(os.Getenv("VAPID_PRIVATE_KEY")),
|
||||
VAPIDSubject: strings.TrimSpace(os.Getenv("VAPID_SUBJECT")),
|
||||
Media: MediaConfig{
|
||||
MaxImageMB: envInt("MEDIA_MAX_IMAGE_MB", 15, 1, 1000),
|
||||
MaxAudioMB: envInt("MEDIA_MAX_AUDIO_MB", 50, 1, 2000),
|
||||
QuotaMB: envInt("MEDIA_QUOTA_MB", 0, 0, 10000000),
|
||||
DemoMaxMB: envInt("MEDIA_DEMO_MAX_MB", 3, 1, 100),
|
||||
},
|
||||
Legal: LegalTexts{
|
||||
ImprintURL: legalURL("LEGAL_IMPRINT_URL"),
|
||||
ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"),
|
||||
PrivacyURL: legalURL("LEGAL_PRIVACY_URL"),
|
||||
PrivacyContent: legalContent("LEGAL_PRIVACY_CONTENT"),
|
||||
},
|
||||
Legal: func() LegalTexts {
|
||||
customLabel, customURL := legalCustomLink("LEGAL_CUSTOM_LINK_LABEL", "LEGAL_CUSTOM_LINK_URL")
|
||||
return LegalTexts{
|
||||
ImprintURL: legalURL("LEGAL_IMPRINT_URL"),
|
||||
ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"),
|
||||
PrivacyURL: legalURL("LEGAL_PRIVACY_URL"),
|
||||
PrivacyContent: legalContent("LEGAL_PRIVACY_CONTENT"),
|
||||
CustomLinkLabel: customLabel,
|
||||
CustomLinkURL: customURL,
|
||||
}
|
||||
}(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,3 +81,42 @@ func TestJWTSecretNeverUsesAKnownDefault(t *testing.T) {
|
||||
t.Fatalf("a real JWT_SECRET must win, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomLinkValidation(t *testing.T) {
|
||||
t.Setenv("DATA_DIR", t.TempDir())
|
||||
|
||||
// 1. Valid https URL and label
|
||||
t.Setenv("LEGAL_CUSTOM_LINK_LABEL", "Unternehmensseite")
|
||||
t.Setenv("LEGAL_CUSTOM_LINK_URL", "https://example.com/company")
|
||||
cfg := Load()
|
||||
if cfg.Legal.CustomLinkLabel != "Unternehmensseite" {
|
||||
t.Errorf("expected label 'Unternehmensseite', got %q", cfg.Legal.CustomLinkLabel)
|
||||
}
|
||||
if cfg.Legal.CustomLinkURL != "https://example.com/company" {
|
||||
t.Errorf("expected url 'https://example.com/company', got %q", cfg.Legal.CustomLinkURL)
|
||||
}
|
||||
|
||||
// 2. Insecure http URL -> ignored
|
||||
t.Setenv("LEGAL_CUSTOM_LINK_LABEL", "Insecure Site")
|
||||
t.Setenv("LEGAL_CUSTOM_LINK_URL", "http://insecure.example.com")
|
||||
cfg = Load()
|
||||
if cfg.Legal.CustomLinkURL != "" || cfg.Legal.CustomLinkLabel != "" {
|
||||
t.Errorf("expected empty custom link for http, got url=%q label=%q", cfg.Legal.CustomLinkURL, cfg.Legal.CustomLinkLabel)
|
||||
}
|
||||
|
||||
// 3. Invalid scheme (javascript:) -> ignored
|
||||
t.Setenv("LEGAL_CUSTOM_LINK_LABEL", "Exploit")
|
||||
t.Setenv("LEGAL_CUSTOM_LINK_URL", "javascript:alert(1)")
|
||||
cfg = Load()
|
||||
if cfg.Legal.CustomLinkURL != "" || cfg.Legal.CustomLinkLabel != "" {
|
||||
t.Errorf("expected empty custom link for javascript:, got url=%q label=%q", cfg.Legal.CustomLinkURL, cfg.Legal.CustomLinkLabel)
|
||||
}
|
||||
|
||||
// 4. Empty URL -> empty
|
||||
t.Setenv("LEGAL_CUSTOM_LINK_LABEL", "Only Label")
|
||||
t.Setenv("LEGAL_CUSTOM_LINK_URL", "")
|
||||
cfg = Load()
|
||||
if cfg.Legal.CustomLinkURL != "" || cfg.Legal.CustomLinkLabel != "" {
|
||||
t.Errorf("expected empty custom link when url is empty, got url=%q label=%q", cfg.Legal.CustomLinkURL, cfg.Legal.CustomLinkLabel)
|
||||
}
|
||||
}
|
||||
@@ -158,10 +158,11 @@ type ChannelChoice struct {
|
||||
Email bool `json:"email"`
|
||||
Discord bool `json:"discord"`
|
||||
Webhook bool `json:"webhook"` // own webhook (customWebhookUrl), JSON payload
|
||||
Push bool `json:"push"` // web push to the user's browsers and phones
|
||||
}
|
||||
|
||||
// Any reports whether at least one channel is chosen.
|
||||
func (c ChannelChoice) Any() bool { return c.Email || c.Discord || c.Webhook }
|
||||
func (c ChannelChoice) Any() bool { return c.Email || c.Discord || c.Webhook || c.Push }
|
||||
|
||||
type DailySummary struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -169,6 +170,7 @@ type DailySummary struct {
|
||||
Email bool `json:"email"`
|
||||
Discord bool `json:"discord"`
|
||||
Webhook bool `json:"webhook"`
|
||||
Push bool `json:"push"`
|
||||
}
|
||||
|
||||
// DefaultNotificationSettings for users who never saved any; task alarms follow the old alarm channels.
|
||||
|
||||
@@ -740,17 +740,26 @@ func indentWidth(s string) int {
|
||||
func MarkdownToBlocks(md string) []domain.DocumentBlock {
|
||||
lines := strings.Split(md, "\n")
|
||||
var blocks []domain.DocumentBlock
|
||||
base := 0 // 1 inside a heading section
|
||||
var indents []int // indent widths of the open list levels
|
||||
inList := false // the previous line belonged to a list item (for continuation lines)
|
||||
base := 0 // level of content in the current heading section
|
||||
var headings []int // ranks (1–3) of the open headings: H2 below H1 nests one level deeper
|
||||
var indents []int // indent widths of the open list levels
|
||||
inList := false // the previous line belonged to a list item (for continuation lines)
|
||||
// A list right after a paragraph ("Folgende Punkte:") nests under that paragraph
|
||||
afterParagraph, listBase := false, 0
|
||||
listLevel := func(width int) int {
|
||||
if len(indents) == 0 {
|
||||
listBase = base
|
||||
if afterParagraph {
|
||||
listBase = base + 1
|
||||
}
|
||||
}
|
||||
for len(indents) > 0 && indents[len(indents)-1] > width {
|
||||
indents = indents[:len(indents)-1]
|
||||
}
|
||||
if len(indents) == 0 || indents[len(indents)-1] < width {
|
||||
indents = append(indents, width)
|
||||
}
|
||||
return base + len(indents) - 1
|
||||
return listBase + len(indents) - 1
|
||||
}
|
||||
|
||||
for i := 0; i < len(lines); i++ {
|
||||
@@ -780,7 +789,7 @@ func MarkdownToBlocks(md string) []domain.DocumentBlock {
|
||||
b := newBlock("code", strings.Join(code, "\n"), level)
|
||||
b.Lang = lang
|
||||
blocks = append(blocks, b)
|
||||
inList = false
|
||||
inList, afterParagraph = false, false
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -789,15 +798,23 @@ func MarkdownToBlocks(md string) []domain.DocumentBlock {
|
||||
if typ == "" {
|
||||
typ = "h3"
|
||||
}
|
||||
blocks = append(blocks, newBlock(typ, strings.TrimSpace(m[2]), 0))
|
||||
base, indents, inList = 1, nil, false
|
||||
rank := len(m[1])
|
||||
if rank > 3 {
|
||||
rank = 3
|
||||
}
|
||||
for len(headings) > 0 && headings[len(headings)-1] >= rank {
|
||||
headings = headings[:len(headings)-1]
|
||||
}
|
||||
blocks = append(blocks, newBlock(typ, strings.TrimSpace(m[2]), len(headings)))
|
||||
headings = append(headings, rank)
|
||||
base, indents, inList, afterParagraph = len(headings), nil, false, false
|
||||
continue
|
||||
}
|
||||
|
||||
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
|
||||
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
|
||||
blocks = append(blocks, newBlock("divider", "", 0))
|
||||
base, indents, inList = 0, nil, false
|
||||
base, headings, indents, inList, afterParagraph = 0, nil, nil, false, false
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -816,7 +833,7 @@ func MarkdownToBlocks(md string) []domain.DocumentBlock {
|
||||
}
|
||||
i--
|
||||
blocks = append(blocks, newBlock("callout", strings.Join(parts, "\n"), base))
|
||||
indents, inList = nil, false
|
||||
indents, inList, afterParagraph = nil, false, false
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -866,7 +883,7 @@ func MarkdownToBlocks(md string) []domain.DocumentBlock {
|
||||
}
|
||||
i--
|
||||
blocks = append(blocks, newBlock("bullet", strings.Join(para, "\n"), base))
|
||||
indents, inList = nil, false
|
||||
indents, inList, afterParagraph = nil, false, true
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -219,3 +220,30 @@ func TestImportBackupZip(t *testing.T) {
|
||||
}
|
||||
checkRestored(t, store, ben)
|
||||
}
|
||||
|
||||
func TestMarkdownHeadingsNestTheirSections(t *testing.T) {
|
||||
blocks := MarkdownToBlocks("# Projekt\nEinleitung\n## Teil A\n- Punkt\n - Unterpunkt\n## Teil B\n1. Erstens\n# Anhang\nText")
|
||||
var got []string
|
||||
for _, b := range blocks {
|
||||
got = append(got, fmt.Sprintf("%d:%s:%s", b.Level, b.Type, b.Content))
|
||||
}
|
||||
want := []string{
|
||||
"0:h1:Projekt", "1:bullet:Einleitung", "1:h2:Teil A", "2:bullet:Punkt", "3:bullet:Unterpunkt",
|
||||
"1:h2:Teil B", "2:numbered:Erstens", "0:h1:Anhang", "1:bullet:Text",
|
||||
}
|
||||
if strings.Join(got, "|") != strings.Join(want, "|") {
|
||||
t.Fatalf("got %v\nwant %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownListNestsUnderItsParagraph(t *testing.T) {
|
||||
blocks := MarkdownToBlocks("# Plan\nFolgende Punkte:\n\n- eins\n - tiefer\n- zwei\n\nDanach Text\n\n1. a")
|
||||
var got []string
|
||||
for _, b := range blocks {
|
||||
got = append(got, fmt.Sprintf("%d:%s:%s", b.Level, b.Type, b.Content))
|
||||
}
|
||||
want := []string{"0:h1:Plan", "1:bullet:Folgende Punkte:", "2:bullet:eins", "3:bullet:tiefer", "2:bullet:zwei", "1:bullet:Danach Text", "2:numbered:a"}
|
||||
if strings.Join(got, "|") != strings.Join(want, "|") {
|
||||
t.Fatalf("got %v\nwant %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ var migrations = []migration{
|
||||
{6, "Link-Vorschauen", (*Store).migrateLinkPreviews},
|
||||
{7, "Benachrichtigungen", (*Store).migrateNotifications},
|
||||
{8, "Journal", (*Store).migrateJournal},
|
||||
{9, "Web-Push", (*Store).migratePush},
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Web push: the browsers (devices) of a user that receive notifications, and the server's VAPID key
|
||||
// pair when it is not given by environment.
|
||||
|
||||
type PushSubscription struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID string `json:"-"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
P256dh string `json:"-"`
|
||||
Auth string `json:"-"`
|
||||
Device string `json:"device"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (s *Store) migratePush() error {
|
||||
return s.execInTx(
|
||||
stmt(`CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
device TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
)`),
|
||||
stmt(`CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id)`),
|
||||
stmt(`CREATE TABLE IF NOT EXISTS push_keys (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
public_key TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL
|
||||
)`),
|
||||
)
|
||||
}
|
||||
|
||||
// SavePushSubscription stores a device; an endpoint already known moves to this user.
|
||||
func (s *Store) SavePushSubscription(userID, endpoint, p256dh, auth, device string) error {
|
||||
_, err := s.db.Exec(`INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, device, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET user_id = excluded.user_id, p256dh = excluded.p256dh,
|
||||
auth = excluded.auth, device = excluded.device`,
|
||||
userID, endpoint, p256dh, auth, device, time.Now().UTC().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeletePushSubscription(userID, endpoint string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?`, userID, endpoint)
|
||||
return err
|
||||
}
|
||||
|
||||
// ForgetPushEndpoint removes a device the push service no longer knows (HTTP 404/410).
|
||||
func (s *Store) ForgetPushEndpoint(endpoint string) {
|
||||
_, _ = s.db.Exec(`DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint)
|
||||
}
|
||||
|
||||
func (s *Store) PushSubscriptions(userID string) ([]PushSubscription, error) {
|
||||
rows, err := s.db.Query(`SELECT id, user_id, endpoint, p256dh, auth, device, created_at
|
||||
FROM push_subscriptions WHERE user_id = ? ORDER BY id`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []PushSubscription{}
|
||||
for rows.Next() {
|
||||
var p PushSubscription
|
||||
if err := rows.Scan(&p.ID, &p.UserID, &p.Endpoint, &p.P256dh, &p.Auth, &p.Device, &p.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PushKeys returns the stored VAPID key pair; generate is called (once) when there is none yet.
|
||||
func (s *Store) PushKeys(generate func() (public, private string, err error)) (string, string, error) {
|
||||
var pub, priv string
|
||||
err := s.db.QueryRow(`SELECT public_key, private_key FROM push_keys WHERE id = 1`).Scan(&pub, &priv)
|
||||
if err == nil {
|
||||
return pub, priv, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", err
|
||||
}
|
||||
if pub, priv, err = generate(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if _, err := s.db.Exec(`INSERT OR IGNORE INTO push_keys (id, public_key, private_key) VALUES (1, ?, ?)`, pub, priv); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// Another start may have won the race: use what is stored
|
||||
err = s.db.QueryRow(`SELECT public_key, private_key FROM push_keys WHERE id = 1`).Scan(&pub, &priv)
|
||||
return pub, priv, err
|
||||
}
|
||||
Reference in new issue
Block a user