Merge dev into main (v0.64.1)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 30s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 30s
This commit is contained in:
commit
064c296937
8 files changed
+103
-10
No files matched your search
+7
-1
@@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.64.1] - 2026-11-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Zurück-Taste: Auch der Wechsel der Aufgabenliste und der Einkaufsliste ist jetzt ein eigener Schritt; Zurück führt zur vorher offenen Liste.
|
||||||
|
|
||||||
## [0.64.0] - 2026-11-02
|
## [0.64.0] - 2026-11-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -917,7 +922,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.64.0...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.64.1...dev
|
||||||
|
[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
|
||||||
[0.63.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.63.1...v0.63.2
|
[0.63.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.63.1...v0.63.2
|
||||||
[0.63.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.63.0...v0.63.1
|
[0.63.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.63.0...v0.63.1
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](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.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.64.0",
|
"version": "0.64.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.64.0",
|
"version": "0.64.1",
|
||||||
"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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.64.0",
|
"version": "0.64.1",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { CountBadge } from '../../components/CountBadge';
|
|||||||
import { ListSwitcher } from '../../components/ListSwitcher';
|
import { ListSwitcher } from '../../components/ListSwitcher';
|
||||||
import { LivePill } from '../../components/LivePill';
|
import { LivePill } from '../../components/LivePill';
|
||||||
import { isPhoneWidth } from '../../lib/breakpoints';
|
import { isPhoneWidth } from '../../lib/breakpoints';
|
||||||
|
import { reportNav, onNavStep, restoringNavPlace } from '../../lib/navHistory';
|
||||||
import { OriginBadge } from '../../components/OriginBadge';
|
import { OriginBadge } from '../../components/OriginBadge';
|
||||||
import { VoiceInputButton } from '../../components/VoiceInputButton';
|
import { VoiceInputButton } from '../../components/VoiceInputButton';
|
||||||
import { FormSheet } from '../../components/FormSheet';
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
@@ -92,8 +93,24 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
const { setGroups, setPrimaryAction } = useMobileActions();
|
const { setGroups, setPrimaryAction } = useMobileActions();
|
||||||
const [lists, setLists] = useState<SharedList[]>([]);
|
const [lists, setLists] = useState<SharedList[]>([]);
|
||||||
// The open list is remembered on this device (other tabs of the app, new tab, restart)
|
// The open list is remembered on this device (other tabs of the app, new tab, restart)
|
||||||
const [activeListId, setActiveListId] = useState<string>(
|
const [activeListId, setActiveListId] = useState<string>(() => {
|
||||||
() => initialListId || readViewMemory<{ listId: string }>('lists').listId || ''
|
// Coming back here by the browser's back/forward: the list of that step
|
||||||
|
const step = restoringNavPlace();
|
||||||
|
return (step?.tab === 'lists' && step.shopList) || initialListId || readViewMemory<{ listId: string }>('lists').listId || '';
|
||||||
|
});
|
||||||
|
// Switching lists is a step of the browser history (lib/navHistory.ts)
|
||||||
|
const autoSelectedListRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const replace = autoSelectedListRef.current;
|
||||||
|
autoSelectedListRef.current = false;
|
||||||
|
if (activeListId) reportNav({ shopList: activeListId }, { replace });
|
||||||
|
}, [activeListId]);
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
onNavStep((place) => {
|
||||||
|
if (place.tab === 'lists' && place.shopList) setActiveListId(place.shopList);
|
||||||
|
}),
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeListId) writeViewMemory('lists', { listId: activeListId });
|
if (activeListId) writeViewMemory('lists', { listId: activeListId });
|
||||||
@@ -167,6 +184,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
if (initialListId && deduplicated.some((l) => l.id === initialListId)) {
|
if (initialListId && deduplicated.some((l) => l.id === initialListId)) {
|
||||||
setActiveListId(initialListId);
|
setActiveListId(initialListId);
|
||||||
} else if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) {
|
} else if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) {
|
||||||
|
autoSelectedListRef.current = true; // chosen by the app, not a step of its own in the history
|
||||||
setActiveListId(deduplicated[0].id);
|
setActiveListId(deduplicated[0].id);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
|||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
import { ListSwitcher } from '../../components/ListSwitcher';
|
import { ListSwitcher } from '../../components/ListSwitcher';
|
||||||
import { isPhoneWidth } from '../../lib/breakpoints';
|
import { isPhoneWidth } from '../../lib/breakpoints';
|
||||||
|
import { reportNav, onNavStep, restoringNavPlace } from '../../lib/navHistory';
|
||||||
import { LivePill } from '../../components/LivePill';
|
import { LivePill } from '../../components/LivePill';
|
||||||
import { FormSheet } from '../../components/FormSheet';
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
||||||
@@ -181,7 +182,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
const [tasks, setTasks] = useState<Thought[]>([]);
|
const [tasks, setTasks] = useState<Thought[]>([]);
|
||||||
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
|
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
|
||||||
const [selectedListId, setSelectedListIdState] = useState<string>(
|
const [selectedListId, setSelectedListIdState] = useState<string>(
|
||||||
() => remembered.listId || 'all'
|
() => {
|
||||||
|
// Coming back here by the browser's back/forward: the list of that step
|
||||||
|
const step = restoringNavPlace();
|
||||||
|
return (step?.tab === 'tasks' && step.taskList) || remembered.listId || 'all';
|
||||||
|
}
|
||||||
);
|
);
|
||||||
const viewMode: ViewMode = viewModes[selectedListId] || 'list';
|
const viewMode: ViewMode = viewModes[selectedListId] || 'list';
|
||||||
// Only Kanban is stored; the list view is the default
|
// Only Kanban is stored; the list view is the default
|
||||||
@@ -365,6 +370,16 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
setSelectedListIdState(id);
|
setSelectedListIdState(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Switching task lists is a step of the browser history (lib/navHistory.ts)
|
||||||
|
useEffect(() => reportNav({ taskList: selectedListId }), [selectedListId]);
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
onNavStep((place) => {
|
||||||
|
if (place.tab === 'tasks' && place.taskList) setSelectedListId(place.taskList);
|
||||||
|
}),
|
||||||
|
[setSelectedListId]
|
||||||
|
);
|
||||||
|
|
||||||
const onRefreshStatsRef = useRef(onRefreshStats);
|
const onRefreshStatsRef = useRef(onRefreshStats);
|
||||||
onRefreshStatsRef.current = onRefreshStats;
|
onRefreshStatsRef.current = onRefreshStats;
|
||||||
// The realtime handler is registered once; it must reload with the current list, not the first one
|
// The realtime handler is registered once; it must reload with the current list, not the first one
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// In-app back and forward: where the user is (page, open note, workspace view, zoomed bullet, on phones
|
// In-app back and forward: where the user is (page, open note, workspace view, zoomed bullet, on phones
|
||||||
// list or note) goes into the browser history, so the browser's back button (mouse button, Android back)
|
// list or note, open task list and list) goes into the browser history, so the browser's back button (mouse button, Android back)
|
||||||
// steps back inside the app instead of leaving it. Each part reports its own piece; changes from one
|
// steps back inside the app instead of leaving it. Each part reports its own piece; changes from one
|
||||||
// action (e.g. opening a note from a task switches page and note) become one entry. While the app follows
|
// action (e.g. opening a note from a task switches page and note) become one entry. While the app follows
|
||||||
// a back/forward step, reports change nothing: the step itself already is the entry.
|
// a back/forward step, reports change nothing: the step itself already is the entry.
|
||||||
@@ -10,6 +10,8 @@ export interface NavPlace {
|
|||||||
view?: string | null; // workspace view: editor, todos, tags, trash, journal …
|
view?: string | null; // workspace view: editor, todos, tags, trash, journal …
|
||||||
zoom?: string | null; // bullet the open note is zoomed into
|
zoom?: string | null; // bullet the open note is zoomed into
|
||||||
detail?: boolean; // phones: the note (true) or the list of notes (false)
|
detail?: boolean; // phones: the note (true) or the list of notes (false)
|
||||||
|
taskList?: string | null; // open task list on the tasks page ("all" for Alle Aufgaben)
|
||||||
|
shopList?: string | null; // open list on the lists page
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESTORE_MS = 600; // the app needs a moment to follow a step (views mount, notes load)
|
const RESTORE_MS = 600; // the app needs a moment to follow a step (views mount, notes load)
|
||||||
@@ -25,7 +27,9 @@ const same = (a: NavPlace, b: NavPlace) =>
|
|||||||
(a.docId ?? null) === (b.docId ?? null) &&
|
(a.docId ?? null) === (b.docId ?? null) &&
|
||||||
(a.view ?? null) === (b.view ?? null) &&
|
(a.view ?? null) === (b.view ?? null) &&
|
||||||
(a.zoom ?? null) === (b.zoom ?? null) &&
|
(a.zoom ?? null) === (b.zoom ?? null) &&
|
||||||
Boolean(a.detail) === Boolean(b.detail);
|
Boolean(a.detail) === Boolean(b.detail) &&
|
||||||
|
(a.taskList ?? null) === (b.taskList ?? null) &&
|
||||||
|
(a.shopList ?? null) === (b.shopList ?? null);
|
||||||
|
|
||||||
function flush() {
|
function flush() {
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
|
|||||||
@@ -125,6 +125,56 @@ func TestImportMindSiftJSONRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A backup changed by hand cannot raise the importer's role or reach other accounts: the profile in it
|
||||||
|
// (role, id) is ignored, and notes and blocks get new IDs and no task links.
|
||||||
|
func TestImportIgnoresProfileAndForeignIDs(t *testing.T) {
|
||||||
|
store, anna, ben := newImportStore(t)
|
||||||
|
annaDoc, _ := store.CreateDocument(anna.ID, "", "Annas Notiz", "", "", []domain.DocumentBlock{{ID: "blk_anna", Type: "bullet", Content: "Original"}}, nil, false)
|
||||||
|
annaTask, _ := store.CreateThoughtWithDetails(anna.ID, "Annas Aufgabe", "normal", "manual", "", "", "todo", "")
|
||||||
|
|
||||||
|
exp := domain.FullExport{
|
||||||
|
ExportedAt: "2026-11-02T00:00:00Z",
|
||||||
|
Version: "manipuliert",
|
||||||
|
User: domain.User{ID: anna.ID, Email: anna.Email, Role: "admin", IsActive: true},
|
||||||
|
Documents: []domain.Document{{
|
||||||
|
ID: annaDoc.ID,
|
||||||
|
Title: "Übernommen?",
|
||||||
|
Blocks: []domain.DocumentBlock{
|
||||||
|
{ID: "blk_anna", Type: "bullet", Content: "Überschrieben?"},
|
||||||
|
{ID: "blk_x", Type: "todo", Content: "Verknüpft?", TaskID: annaTask.ID},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(exp)
|
||||||
|
plan, err := PlanMindSiftJSONImport(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("plan: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.ApplyImport(ben.ID, plan); err != nil {
|
||||||
|
t.Fatalf("apply: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if u, _ := store.GetUserByID(ben.ID); u.Role != "member" {
|
||||||
|
t.Errorf("import must not change the role, got %q", u.Role)
|
||||||
|
}
|
||||||
|
if u, _ := store.GetUserByID(anna.ID); u.Role != anna.Role {
|
||||||
|
t.Errorf("import must not touch the account named in the backup, role now %q", u.Role)
|
||||||
|
}
|
||||||
|
if d, err := store.GetDocumentByID(anna.ID, annaDoc.ID); err != nil || d.Title != "Annas Notiz" || d.Blocks[0].Content != "Original" {
|
||||||
|
t.Errorf("Anna's note must stay as it was: %+v %v", d, err)
|
||||||
|
}
|
||||||
|
docs, _ := store.GetDocuments(ben.ID)
|
||||||
|
if len(docs) != 1 || docs[0].ID == annaDoc.ID || docs[0].Title != "Übernommen?" {
|
||||||
|
t.Fatalf("the note should be Ben's own copy with a new ID: %+v", docs)
|
||||||
|
}
|
||||||
|
got, _ := store.GetDocumentByID(ben.ID, docs[0].ID)
|
||||||
|
for _, b := range got.Blocks {
|
||||||
|
if b.ID == "blk_anna" || b.ID == "blk_x" || b.TaskID != "" {
|
||||||
|
t.Errorf("imported blocks need new IDs and no task links: %+v", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func zipOf(t *testing.T, files map[string]string) []byte {
|
func zipOf(t *testing.T, files map[string]string) []byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|||||||
Reference in new issue
Block a user