Merge dev into main (v0.66.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 33s

This commit is contained in:
sandrodach committed 2026-11-02 22:24:21 +01:00
commit ff0c47fa15
8 files changed
+184 -13

No files matched your search

+11 -1
View File
@@ -6,6 +6,15 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [Unreleased]
## [0.66.0] - 2026-11-02
### Added
- Notizen: Strg+C über mehrere markierte Punkte kopiert sie als Markdown (Einrückung, To-dos, Überschriften, Zitate, Code). In andere Programme eingefügt ist das eine saubere Liste, in eine Notiz eingefügt entstehen wieder dieselben Punkte.
- Notizen: Ein zweites Strg+A in einem Punkt markiert die ganze Notiz, etwa um sie mit Strg+C komplett als Markdown zu kopieren.
### Fixed
- Notizen: Kopieren über mehrere Punkte funktioniert auch auf Installationen ohne HTTPS.
## [0.65.0] - 2026-11-02 ## [0.65.0] - 2026-11-02
### Changed ### Changed
@@ -927,7 +936,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. - 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. - 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.65.0...dev [Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.66.0...dev
[0.66.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.65.0...v0.66.0
[0.65.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.64.1...v0.65.0 [0.65.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.64.1...v0.65.0
[0.64.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.64.0...v0.64.1 [0.64.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.64.0...v0.64.1
[0.64.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.63.2...v0.64.0 [0.64.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.63.2...v0.64.0
+1 -1
View File
@@ -6,7 +6,7 @@
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#)
[![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart)
[![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir)
[![Version](https://img.shields.io/badge/Version-v0.65.0-16a34a?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/Version-v0.66.0-16a34a?style=flat-square)](CHANGELOG.md)
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.65.0", "version": "0.66.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.65.0", "version": "0.66.0",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "5.3.0", "@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"private": true, "private": true,
"version": "0.65.0", "version": "0.66.0",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"type": "module", "type": "module",
"scripts": { "scripts": {
+2 -1
View File
@@ -761,7 +761,8 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
{ key: '↑ / ↓', scope: 'Workspace Outliner', desc: 'Zum vorherigen / nächsten Block navigieren' }, { key: '↑ / ↓', scope: 'Workspace Outliner', desc: 'Zum vorherigen / nächsten Block navigieren' },
{ key: 'Strg + Z / Cmd + Z', scope: 'Workspace Outliner', desc: 'Letzte Notiz-Aktion rückgängig machen' }, { key: 'Strg + Z / Cmd + Z', scope: 'Workspace Outliner', desc: 'Letzte Notiz-Aktion rückgängig machen' },
{ key: 'Strg + Y / Strg + Shift + Z', scope: 'Workspace Outliner', desc: 'Rückgängig gemachte Aktion wiederholen' }, { key: 'Strg + Y / Strg + Shift + Z', scope: 'Workspace Outliner', desc: 'Rückgängig gemachte Aktion wiederholen' },
{ key: 'Maus über mehrere Punkte ziehen', scope: 'Workspace Outliner', desc: 'Text über mehrere Punkte markieren; Entf/Backspace löscht ihn, Tippen ersetzt ihn, Strg + C kopiert' }, { key: 'Maus über mehrere Punkte ziehen', scope: 'Workspace Outliner', desc: 'Text über mehrere Punkte markieren; Entf/Backspace löscht ihn, Tippen ersetzt ihn, Strg + C kopiert ihn als Markdown (Einrückung, To-dos, Überschriften)' },
{ key: 'Strg + A zweimal', scope: 'Workspace Outliner', desc: 'Erst den Punkt, beim zweiten Mal die ganze Notiz markieren; Strg + C kopiert sie als Markdown, eingefügt entstehen wieder dieselben Punkte' },
{ key: 'Strg + Z / Cmd + Z', scope: 'Aufgaben', desc: 'Löschen, Verschieben oder Abhaken rückgängig machen' }, { key: 'Strg + Z / Cmd + Z', scope: 'Aufgaben', desc: 'Löschen, Verschieben oder Abhaken rückgängig machen' },
{ key: '/', scope: 'Workspace Outliner', desc: 'Slash-Menü öffnen (/todo, /link, /h1, /code, ...)' }, { key: '/', scope: 'Workspace Outliner', desc: 'Slash-Menü öffnen (/todo, /link, /h1, /code, ...)' },
{ key: '[] oder [ ] am Anfang', scope: 'Workspace Outliner', desc: 'Sofort in Todo-Checkbox umwandeln' }, { key: '[] oder [ ] am Anfang', scope: 'Workspace Outliner', desc: 'Sofort in Todo-Checkbox umwandeln' },
@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, u
import { DocumentBlock, BlockType } from '../../lib/types'; import { DocumentBlock, BlockType } from '../../lib/types';
import { OutlinerBlock, mapRenderedOffsetToRaw } from './OutlinerBlock'; import { OutlinerBlock, mapRenderedOffsetToRaw } from './OutlinerBlock';
import { parsePastedLines } from '../../lib/pasteOutline'; import { parsePastedLines } from '../../lib/pasteOutline';
import { blocksToMarkdown } from '../../lib/outlineMarkdown';
import { numberedLabels } from '../../lib/outlineList'; import { numberedLabels } from '../../lib/outlineList';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import { MobileOutlineBar, isTouchOnly } from './MobileOutlineBar'; import { MobileOutlineBar, isTouchOnly } from './MobileOutlineBar';
@@ -47,6 +48,24 @@ export interface OutlinerTreeProps {
trailingArea?: boolean; trailingArea?: boolean;
} }
// Puts text on the clipboard from a key press: through the browser's copy event, which works on plain
// HTTP too (navigator.clipboard needs HTTPS or localhost); the clipboard API only as a fallback
function writeClipboardText(text: string) {
const onCopy = (e: ClipboardEvent) => {
e.clipboardData?.setData('text/plain', text);
e.preventDefault();
};
document.addEventListener('copy', onCopy);
let copied = false;
try {
copied = document.execCommand('copy');
} catch {
// not supported: fall back below
}
document.removeEventListener('copy', onCopy);
if (!copied) void navigator.clipboard?.writeText(text).catch(() => {});
}
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
docId, docId,
compact = false, compact = false,
@@ -679,7 +698,8 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
// bullet into another is tracked here instead: start (anchor) and end (focus) as bullet + position in // bullet into another is tracked here instead: start (anchor) and end (focus) as bullet + position in
// its raw text. The start bullet keeps the browser's own selection; the others are highlighted with the // its raw text. The start bullet keeps the browser's own selection; the others are highlighted with the
// CSS Highlight API (rows are tinted where it is missing). Delete/Backspace removes the selected text // CSS Highlight API (rows are tinted where it is missing). Delete/Backspace removes the selected text
// like a text editor in one step (Strg+Z brings it back), a typed character replaces it, Strg+C copies. // like a text editor in one step (Strg+Z brings it back), a typed character replaces it, Strg+C copies
// it as Markdown. A second Strg+A in a bullet selects the whole note.
type RangeSel = { anchorId: string; anchorOffset: number; focusId: string; focusOffset: number; focusNode?: Node; focusNodeOffset?: number }; type RangeSel = { anchorId: string; anchorOffset: number; focusId: string; focusOffset: number; focusNode?: Node; focusNodeOffset?: number };
const [rangeSel, setRangeSel] = useState<RangeSel | null>(null); const [rangeSel, setRangeSel] = useState<RangeSel | null>(null);
const rangeSelRef = useRef<RangeSel | null>(null); const rangeSelRef = useRef<RangeSel | null>(null);
@@ -819,13 +839,30 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
return { first: blocks[fIdx], firstOffset, last: blocks[lIdx], lastOffset, fIdx, lIdx }; return { first: blocks[fIdx], firstOffset, last: blocks[lIdx], lastOffset, fIdx, lIdx };
}; };
// The selection as Markdown (lib/outlineMarkdown.ts): pasted elsewhere it is a proper list, pasted
// back into a note it becomes the same bullets again
const selectedText = (sel: RangeSel) => { const selectedText = (sel: RangeSel) => {
const r = resolveRangeSel(sel); const r = resolveRangeSel(sel);
if (!r) return ''; if (!r) return '';
const lines = [(r.first.content || '').slice(r.firstOffset)]; return blocksToMarkdown([
for (let i = r.fIdx + 1; i < r.lIdx; i++) lines.push(blocks[i].content || ''); { ...r.first, content: (r.first.content || '').slice(r.firstOffset) },
lines.push((r.last.content || '').slice(0, r.lastOffset)); ...blocks.slice(r.fIdx + 1, r.lIdx),
return lines.join('\n'); { ...r.last, content: (r.last.content || '').slice(0, r.lastOffset) },
]);
};
// Ctrl/Cmd+A a second time (the bullet's own text is selected already): the whole note, ready for
// Ctrl+C as Markdown
const handleTreeKeyDownCapture = (e: React.KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey || e.key.toLowerCase() !== 'a') return;
const ta = e.target as HTMLTextAreaElement;
if (ta.tagName !== 'TEXTAREA' || ta.selectionStart !== 0 || ta.selectionEnd !== ta.value.length) return;
if (visibleEntries.length < 2) return;
e.preventDefault();
const first = visibleEntries[0].block;
const last = visibleEntries[visibleEntries.length - 1].block;
focusBlockInput(first.id, 'select');
setRangeSel({ anchorId: first.id, anchorOffset: 0, focusId: last.id, focusOffset: (last.content || '').length });
}; };
const deleteRangeSel = (sel: RangeSel, insert = '') => { const deleteRangeSel = (sel: RangeSel, insert = '') => {
@@ -882,7 +919,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
}; };
if (mod && (e.key === 'c' || e.key === 'x')) { if (mod && (e.key === 'c' || e.key === 'x')) {
stop(); stop();
void navigator.clipboard?.writeText(selectedTextRef.current(sel)).catch(() => {}); writeClipboardText(selectedTextRef.current(sel));
if (e.key === 'x') deleteRangeSelRef.current(sel); if (e.key === 'x') deleteRangeSelRef.current(sel);
return; return;
} }
@@ -912,7 +949,12 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
}, [rangeSel, visibleEntries, highlightSupported]); }, [rangeSel, visibleEntries, highlightSupported]);
return ( return (
<div className="space-y-0.5" ref={treeRootRef} onMouseDownCapture={handleTreeMouseDownCapture}> <div
className="space-y-0.5"
ref={treeRootRef}
onMouseDownCapture={handleTreeMouseDownCapture}
onKeyDownCapture={handleTreeKeyDownCapture}
>
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => { {visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => {
const isLast = entryIdx === visibleEntries.length - 1; const isLast = entryIdx === visibleEntries.length - 1;
return ( return (
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { blocksToMarkdown } from '../outlineMarkdown';
import { parsePastedLines } from '../pasteOutline';
import type { DocumentBlock } from '../types';
const b = (type: DocumentBlock['type'], content: string, level = 0, extra: Partial<DocumentBlock> = {}): DocumentBlock => ({
id: Math.random().toString(36).slice(2),
type,
content,
level,
...extra,
});
describe('blocksToMarkdown', () => {
it('writes a nested list with to-dos, headings and code', () => {
const md = blocksToMarkdown([
b('h2', 'Umzug', 0),
b('bullet', 'Server', 1),
b('todo', 'Backup prüfen', 2, { checked: true }),
b('todo', 'DNS umstellen', 2),
b('numbered', 'Erstens', 1),
b('code', 'docker compose up -d', 1, { lang: 'bash' }),
]);
expect(md).toBe(
['## Umzug', '', '- Server', '\t- [x] Backup prüfen', '\t- [ ] DNS umstellen', '1. Erstens', '```bash', 'docker compose up -d', '```'].join('\n')
);
});
it('starts a copied branch at the left margin', () => {
expect(blocksToMarkdown([b('bullet', 'Ast', 3), b('bullet', 'Kind', 4)])).toBe('- Ast\n\t- Kind');
});
it('pasted back, it gives the same bullets', () => {
// 'text' is the plain bullet of a note
const blocks = [b('text', 'Eins', 0), b('text', 'Zwei', 1), b('todo', 'Drei', 2, { checked: true }), b('text', 'Vier', 0)];
const items = parsePastedLines(blocksToMarkdown(blocks).split('\n'));
expect(items.map((i) => [i.type, i.content, i.indent, Boolean(i.checked)])).toEqual([
['text', 'Eins', 0, false],
['text', 'Zwei', 1, false],
['todo', 'Drei', 2, true],
['text', 'Vier', 0, false],
]);
});
});
+74
View File
@@ -0,0 +1,74 @@
import type { DocumentBlock } from './types';
// Bullets as Markdown, in the format of the server's Markdown export (BlocksToVaultMarkdown): a nested list
// with one tab per level, "- [ ]" for to-dos, headings, dividers, callouts and fenced code at the top
// level as block elements. Used for copying bullets; pasting it back (pasteOutline.ts) gives the same
// structure. Levels count from the shallowest bullet, so a copied branch starts at the left margin.
export function blocksToMarkdown(blocks: DocumentBlock[]): string {
if (blocks.length === 0) return '';
const base = Math.min(...blocks.map((b) => Math.max(0, b.level || 0)));
const out: string[] = [];
// Children of a top-level heading/callout/code/divider become a list one level up; otherwise their
// tab indent would turn them into an indented code block
let lift = 0;
for (const b of blocks) {
let lvl = Math.max(0, (b.level || 0) - base);
if (lvl === 0) lift = 0;
else lvl = Math.max(0, lvl - lift);
const indent = '\t'.repeat(lvl);
const content = (b.content || '').replace(/\r\n/g, '\n');
const oneLine = content.replace(/\n/g, ' ');
// Soft line breaks continue the list item, aligned under its text
const cont = (prefix: string) => content.replace(/\n/g, `\n${indent}${prefix}`);
const hashes = b.type === 'h1' ? '#' : b.type === 'h2' ? '##' : b.type === 'h3' ? '###' : '';
if ((b.level || 0) - base <= 0) {
if (hashes) {
out.push('', `${hashes} ${oneLine}`, '');
lift = 1;
continue;
}
if (b.type === 'divider') {
out.push('', '---', '');
lift = 1;
continue;
}
if (b.type === 'callout') {
out.push('', `> ${content.replace(/\n/g, '\n> ')}`, '');
lift = 1;
continue;
}
if (b.type === 'code') {
out.push('', `\`\`\`${b.lang || ''}`, content, '```', '');
lift = 1;
continue;
}
}
switch (b.type) {
case 'code':
out.push(`${indent}\`\`\`${b.lang || ''}`, `${indent}${content.replace(/\n/g, `\n${indent}`)}`, `${indent}\`\`\``);
break;
case 'todo':
out.push(`${indent}- [${b.checked ? 'x' : ' '}] ${cont(' ')}`);
break;
case 'h1':
case 'h2':
case 'h3':
out.push(`${indent}- ${hashes} ${oneLine}`);
break;
case 'divider':
out.push(`${indent}- ---`);
break;
case 'callout':
out.push(`${indent}- > ${cont(' > ')}`);
break;
case 'numbered':
out.push(`${indent}1. ${cont(' ')}`);
break;
default:
out.push(content.trim() === '' ? `${indent}-` : `${indent}- ${cont(' ')}`);
}
}
// No blank lines at the start and end, at most one in a row
return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+|\n+$/g, '');
}