-
- Neuen Ordner anlegen
-
+
+ {newFolderParentPath ? `Unterordner in "${newFolderParentPath}"` : 'Neuer Ordner'}
-
-
-
- setNewFolderName(e.target.value)}
- placeholder="Projekte"
- className="input-field w-full px-2.5 py-1.5 text-xs"
- />
-
-
-
-
-
+
setNewFolderName(e.target.value)}
+ placeholder="Ordnername"
+ className="input-field w-full px-2 py-1 text-xs"
+ />
+
@@ -2411,14 +2250,14 @@ export const WorkspaceView: React.FC = ({
type="submit"
className="btn-primary px-3 py-1 text-xs font-semibold"
>
- Erstellen
+ OK
)}
- {/* Unified Sharing Modal */}
+ {/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */}
{(shareModalDoc || shareModalFolder) && (
= ({
shares={unifiedShares}
teams={teams}
collaborators={collaborators}
- allowRoleSelection={!shareModalDoc}
- onAddShare={async (targetType, targetId, permission) => {
- const perm = (!shareModalDoc && permission === 'editor') ? 'write' : 'read';
+ allowRoleSelection={false}
+ fixedPermission="viewer"
+ onAddShare={async (targetType, targetId) => {
if (shareModalDoc) {
await api.createDocShare(
shareModalDoc.id,
@@ -2453,7 +2292,7 @@ export const WorkspaceView: React.FC = ({
await api.createFolderShare(shareModalFolder.id, {
targetType,
targetId,
- permission: perm,
+ permission: 'read',
});
const updatedFolder = await api.getSharedFolder(shareModalFolder.id);
setShareModalFolder(updatedFolder);
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index a70d32a..2cd6369 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -649,6 +649,17 @@ export const api = {
};
},
+ async createEnhancedTask(
+ docId: string,
+ blockId: string,
+ payload: { content: string; checked: boolean }
+ ): Promise {
+ return request(`/api/v1/documents/${docId}/blocks/${blockId}/enhanced-task`, {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ });
+ },
+
// --- DOCUMENT SHARING ---
async getDocShares(docId: string): Promise {
const res = await request(`/api/v1/documents/${docId}/shares`);
@@ -796,6 +807,17 @@ export const api = {
});
},
+ async updateListItem(
+ listId: string,
+ itemId: string,
+ payload: { content?: string; category?: string; quantity?: string }
+ ): Promise {
+ return request(`/api/v1/lists/${listId}/items/${itemId}`, {
+ method: 'PUT',
+ body: JSON.stringify(payload),
+ });
+ },
+
async deleteListItem(listId: string, itemId: string): Promise {
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
},
diff --git a/frontend/src/lib/markdown.tsx b/frontend/src/lib/markdown.tsx
new file mode 100644
index 0000000..ab5528b
--- /dev/null
+++ b/frontend/src/lib/markdown.tsx
@@ -0,0 +1,179 @@
+import React from 'react';
+
+/**
+ * Parses inline markdown:
+ * - **bold** or __bold__
+ * - *italic* or _italic_
+ * - ~~strikethrough~~
+ * - `code`
+ * - [label](url)
+ */
+export function renderInlineMarkdown(text: string): React.ReactNode[] {
+ if (!text) return [];
+
+ const regex = /(`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|~~[^~]+~~|\*[^*]+\*|_[^_]+_|\[([^\]]+)\]\(([^)]+)\))/g;
+
+ const elements: React.ReactNode[] = [];
+ let lastIndex = 0;
+ let match: RegExpExecArray | null;
+
+ while ((match = regex.exec(text)) !== null) {
+ if (match.index > lastIndex) {
+ elements.push(text.slice(lastIndex, match.index));
+ }
+ const token = match[0];
+ const key = `${match.index}-${token}`;
+
+ if (token.startsWith('`') && token.endsWith('`')) {
+ elements.push(
+
+ {token.slice(1, -1)}
+
+ );
+ } else if (
+ (token.startsWith('**') && token.endsWith('**')) ||
+ (token.startsWith('__') && token.endsWith('__'))
+ ) {
+ elements.push(
+
+ {token.slice(2, -2)}
+
+ );
+ } else if (
+ (token.startsWith('*') && token.endsWith('*')) ||
+ (token.startsWith('_') && token.endsWith('_'))
+ ) {
+ elements.push(
+
+ {token.slice(1, -1)}
+
+ );
+ } else if (token.startsWith('~~') && token.endsWith('~~')) {
+ elements.push(
+
+ {token.slice(2, -2)}
+
+ );
+ } else if (token.startsWith('[') && match[2] && match[3]) {
+ const label = match[2];
+ const url = match[3];
+ elements.push(
+ e.stopPropagation()}
+ >
+ {label}
+
+ );
+ } else {
+ elements.push(token);
+ }
+
+ lastIndex = regex.lastIndex;
+ }
+
+ if (lastIndex < text.length) {
+ elements.push(text.slice(lastIndex));
+ }
+
+ return elements;
+}
+
+export function MarkdownViewer({ content, className = '' }: { content: string; className?: string }) {
+ if (!content) return null;
+
+ const lines = content.split('\n');
+ const renderedNodes: React.ReactNode[] = [];
+ let inCodeBlock = false;
+ let codeBlockLines: string[] = [];
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+
+ if (line.trim().startsWith('```')) {
+ if (inCodeBlock) {
+ renderedNodes.push(
+
+ {codeBlockLines.join('\n')}
+
+ );
+ codeBlockLines = [];
+ inCodeBlock = false;
+ } else {
+ inCodeBlock = true;
+ codeBlockLines = [];
+ }
+ continue;
+ }
+
+ if (inCodeBlock) {
+ codeBlockLines.push(line);
+ continue;
+ }
+
+ const trimmed = line.trim();
+
+ if (trimmed.startsWith('### ')) {
+ renderedNodes.push(
+
+ {renderInlineMarkdown(trimmed.slice(4))}
+
+ );
+ } else if (trimmed.startsWith('## ')) {
+ renderedNodes.push(
+
+ {renderInlineMarkdown(trimmed.slice(3))}
+
+ );
+ } else if (trimmed.startsWith('# ')) {
+ renderedNodes.push(
+
+ {renderInlineMarkdown(trimmed.slice(2))}
+
+ );
+ } else if (trimmed.startsWith('> ')) {
+ renderedNodes.push(
+
+ {renderInlineMarkdown(trimmed.slice(2))}
+
+ );
+ } else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
+ renderedNodes.push(
+
+ •
+ {renderInlineMarkdown(trimmed.slice(2))}
+
+ );
+ } else if (/^\d+\.\s/.test(trimmed)) {
+ const matchNum = trimmed.match(/^(\d+)\.\s(.*)$/);
+ renderedNodes.push(
+
+ {matchNum ? matchNum[1] + '.' : '•'}
+ {renderInlineMarkdown(matchNum ? matchNum[2] : trimmed)}
+
+ );
+ } else if (trimmed === '') {
+ renderedNodes.push();
+ } else {
+ renderedNodes.push(
+
+ {renderInlineMarkdown(line)}
+
+ );
+ }
+ }
+
+ if (inCodeBlock && codeBlockLines.length > 0) {
+ renderedNodes.push(
+
+ {codeBlockLines.join('\n')}
+
+ );
+ }
+
+ return {renderedNodes}
;
+}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 4ec9d6b..30edbd3 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -53,8 +53,10 @@ export interface Thought {
source: 'manual' | 'voice' | 'webhook';
status: ThoughtStatus;
isCompleted: boolean;
- convertedType?: 'task' | 'document' | 'list_item';
+ convertedType?: 'task' | 'document' | 'list_item' | 'document_block';
convertedRefId?: string;
+ docId?: string;
+ blockId?: string;
dueDate?: string;
taskListId?: string;
kanbanStatus?: string;
@@ -159,6 +161,7 @@ export interface DocumentBlock {
id: string;
docId?: string;
documentId?: string;
+ taskId?: string;
type: BlockType;
content: string;
checked?: boolean;
diff --git a/internal/api/server.go b/internal/api/server.go
index 0a4bca0..56b6561 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -135,6 +135,7 @@ func (s *Server) Router() http.Handler {
r.Patch("/api/v1/documents/{id}/blocks", s.handlePatchDocumentBlocks)
r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument)
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
+ r.Post("/api/v1/documents/{id}/blocks/{blockId}/enhanced-task", s.handleCreateEnhancedTask)
// Document Sharing & Collaboration
r.Get("/api/v1/documents/{id}/shares", s.handleGetDocShares)
@@ -151,6 +152,7 @@ func (s *Server) Router() http.Handler {
r.Delete("/api/v1/lists/{id}/shares/{shareId}", s.handleDeleteListShare)
r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem)
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
+ r.Put("/api/v1/lists/{id}/items/{itemId}", s.handleUpdateListItem)
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
r.Post("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
@@ -828,6 +830,9 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
+ if req.IsCompleted != nil {
+ s.store.SyncDocBlockAndTask(userID, thoughtID, *req.IsCompleted)
+ }
s.wsHub.BroadcastToAll("TASK_UPDATED", updated)
writeJSON(w, http.StatusOK, updated)
}
@@ -835,6 +840,7 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
+ s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -997,6 +1003,30 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
+func (s *Server) handleCreateEnhancedTask(w http.ResponseWriter, r *http.Request) {
+ userID := auth.GetUserID(r.Context())
+ docID := chi.URLParam(r, "id")
+ blockID := chi.URLParam(r, "blockId")
+
+ var req struct {
+ Content string `json:"content"`
+ Checked bool `json:"checked"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(w, http.StatusBadRequest, "Ungültiges JSON")
+ return
+ }
+
+ thought, err := s.store.CreateTaskFromDocBlock(userID, docID, blockID, req.Content, req.Checked)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ s.wsHub.BroadcastToUser(userID, "TASK_CREATED", thought)
+ writeJSON(w, http.StatusCreated, thought)
+}
+
// --- WORKSPACE FOLDERS ---
func (s *Server) handleGetFolders(w http.ResponseWriter, r *http.Request) {
@@ -1574,6 +1604,30 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, item)
}
+func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) {
+ listID := chi.URLParam(r, "id")
+ itemID := chi.URLParam(r, "itemId")
+
+ var req struct {
+ Content *string `json:"content"`
+ Category *string `json:"category"`
+ Quantity *string `json:"quantity"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(w, http.StatusBadRequest, "Ungültiges JSON")
+ return
+ }
+
+ item, err := s.store.UpdateListItem(itemID, req.Content, req.Category, req.Quantity)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ s.wsHub.BroadcastListEvent(listID, "ITEM_UPDATED", item)
+ writeJSON(w, http.StatusOK, item)
+}
+
func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "itemId")
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index 1b84a14..1eb0a72 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -1034,7 +1034,7 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
}
// 7. Synchronous contents check:
- // User B moves the document into a subfolder: "MeineOrdner/TeamProjekt/Archiv"
+ // User B (read-only recipient) cannot move documents within the shared folder:
moveDocPayload := map[string]string{
"folderPath": "MeineOrdner/TeamProjekt/Archiv",
}
@@ -1044,22 +1044,36 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
- if rec.Code != http.StatusOK {
- t.Fatalf("User B move doc into subfolder failed: %s", rec.Body.String())
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("Expected User B move doc in read-only shared folder to fail, got %d", rec.Code)
}
- // User A must now see the document synchronized at "TeamProjekt/Archiv"!
- req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
+ // User A (the owner) moves the document into a subfolder: "TeamProjekt/Archiv"
+ moveDocPayload = map[string]string{
+ "folderPath": "TeamProjekt/Archiv",
+ }
+ b, _ = json.Marshal(moveDocPayload)
+ req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+createdDoc.ID+"/move", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tokenA)
+ req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
- t.Fatalf("User A get doc failed: %s", rec.Body.String())
+ t.Fatalf("User A move doc into subfolder failed: %s", rec.Body.String())
}
- var docASynced domain.Document
- _ = json.Unmarshal(rec.Body.Bytes(), &docASynced)
- if docASynced.FolderPath != "TeamProjekt/Archiv" {
- t.Fatalf("expected User A to see subfolder sync at 'TeamProjekt/Archiv', got %q", docASynced.FolderPath)
+
+ // User B must now see the document synchronized at "MeineOrdner/TeamProjekt/Archiv"!
+ req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
+ req.Header.Set("Authorization", "Bearer "+tokenB)
+ rec = httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("User B get doc failed: %s", rec.Body.String())
+ }
+ var docBSynced domain.Document
+ _ = json.Unmarshal(rec.Body.Bytes(), &docBSynced)
+ if docBSynced.FolderPath != "MeineOrdner/TeamProjekt/Archiv" {
+ t.Fatalf("expected User B to see subfolder sync at 'MeineOrdner/TeamProjekt/Archiv', got %q", docBSynced.FolderPath)
}
}
diff --git a/internal/domain/models.go b/internal/domain/models.go
index 410b229..1eafd4c 100644
--- a/internal/domain/models.go
+++ b/internal/domain/models.go
@@ -148,8 +148,10 @@ type Thought struct {
Source string `json:"source"` // manual | voice | webhook
Status ThoughtStatus `json:"status"`
IsCompleted bool `json:"isCompleted"`
- ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item
+ ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item | document_block
ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID
+ DocID string `json:"docId,omitempty"`
+ BlockID string `json:"blockId,omitempty"`
DueDate string `json:"dueDate,omitempty"`
TaskListID string `json:"taskListId,omitempty"`
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
@@ -270,6 +272,7 @@ type DocumentBlock struct {
Level int `json:"level"`
Position float64 `json:"position"`
Collapsed bool `json:"collapsed,omitempty"`
+ TaskID string `json:"taskId,omitempty"`
}
func CompileBlocksToMarkdown(blocks []DocumentBlock) string {
diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go
index b453bdc..a435c2f 100644
--- a/internal/storage/sqlite.go
+++ b/internal/storage/sqlite.go
@@ -414,9 +414,10 @@ func (s *Store) migrate() error {
// Backfill username for existing accounts where username is empty
_, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
- // Ensure all existing document shares are strictly read-only
+ // Ensure all existing document & folder shares are strictly read-only
_, _ = s.db.Exec(`UPDATE doc_shares SET permission = 'read' WHERE permission != 'read'`)
_, _ = s.db.Exec(`UPDATE doc_team_shares SET permission = 'read' WHERE permission != 'read'`)
+ _, _ = s.db.Exec(`UPDATE workspace_folder_shares SET permission = 'read' WHERE permission != 'read'`)
// Default system settings (public registration disabled by default; invite or admin creation required)
_, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
@@ -911,6 +912,30 @@ func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueD
}
tags := domain.ExtractTags(content)
+ if strings.EqualFold(source, "voice") {
+ hasDiktat := false
+ for _, tag := range tags {
+ if strings.EqualFold(tag, "diktat") {
+ hasDiktat = true
+ break
+ }
+ }
+ if !hasDiktat {
+ tags = append(tags, "diktat")
+ }
+ }
+ if strings.EqualFold(source, "webhook") {
+ hasWebhook := false
+ for _, tag := range tags {
+ if strings.EqualFold(tag, "webhook") {
+ hasWebhook = true
+ break
+ }
+ }
+ if !hasWebhook {
+ tags = append(tags, "webhook")
+ }
+ }
tagsJSON, _ := json.Marshal(tags)
t := &domain.Thought{
@@ -968,6 +993,11 @@ func scanThought(rows interface {
}
t.Tags = []string{}
_ = json.Unmarshal([]byte(tagsStr), &t.Tags)
+ if t.ConvertedType == "document_block" && strings.Contains(t.ConvertedRefID, ":") {
+ parts := strings.SplitN(t.ConvertedRefID, ":", 2)
+ t.DocID = parts[0]
+ t.BlockID = parts[1]
+ }
t.Subtasks = []domain.SubTask{}
if subtasksStr != "" && subtasksStr != "null" {
_ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks)
@@ -1137,23 +1167,23 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
title = title[:72] + "…"
}
blocks := make([]domain.DocumentBlock, 0)
- formattedDate := domain.FormatGermanDateTime(thought.CreatedAt)
- calloutText := "Ursprünglicher Gedanke aus deiner Inbox"
- if formattedDate != "" {
- calloutText += " vom " + formattedDate
- }
+
+ // 17. Wenn ich eine Aufgabe zu einer Notiz mache, muss die erste Zeile das "To-Do" noch mal als "To-Do" enthalten, auch wenn es schon in der Überschrift steht. Wenn es vorher schon abgehakt war, dann den Zustand natürlich übernehmen.
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
- Type: "callout",
- Content: calloutText,
+ Type: "todo",
+ Content: thought.Content,
+ Checked: thought.IsCompleted,
Level: 0,
})
+
+ // 17a. Die anderen Details aus der Aufgabe sollen natürlich ebenfalls übernommen werden (Notizen, Subtasks, Fälligkeit/Priorität).
if strings.TrimSpace(thought.Notes) != "" {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "bullet",
Content: strings.TrimSpace(thought.Notes),
- Level: 0,
+ Level: 1,
})
}
if len(thought.Subtasks) > 0 {
@@ -1163,10 +1193,25 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
Type: "todo",
Content: st.Title,
Checked: st.IsCompleted,
- Level: 0,
+ Level: 1,
})
}
}
+ var metaItems []string
+ if thought.DueDate != "" {
+ metaItems = append(metaItems, "Fällig: "+thought.DueDate)
+ }
+ if thought.Priority != "" && thought.Priority != "normal" {
+ metaItems = append(metaItems, "Priorität: "+thought.Priority)
+ }
+ if len(metaItems) > 0 {
+ blocks = append(blocks, domain.DocumentBlock{
+ ID: domain.NewID("blk"),
+ Type: "bullet",
+ Content: strings.Join(metaItems, " • "),
+ Level: 1,
+ })
+ }
if len(blocks) == 1 {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
@@ -2354,6 +2399,11 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
}
if blocks != nil {
_ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks)
+ for _, b := range doc.Blocks {
+ if b.TaskID != "" {
+ s.SyncTaskFromBlockCheck(doc.UserID, b.TaskID, b.Checked)
+ }
+ }
}
return doc, nil
}
@@ -2888,8 +2938,8 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do
if err != nil {
return nil, err
}
- if !shFolder.IsOwner && shFolder.Permission != "write" {
- return nil, fmt.Errorf("keine Schreibberechtigung für diesen geteilten Ordner")
+ if !shFolder.IsOwner {
+ return nil, fmt.Errorf("nur der Eigentümer dieses geteilten Ordners kann Dateien dorthin verschieben")
}
// Update document to belong to targetSharedFolderID
@@ -3338,8 +3388,8 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde
if err != nil {
return nil, err
}
- if !f.IsOwner && f.Permission != "write" {
- return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Ordner-Freigaben verwalten")
+ if !f.IsOwner {
+ return nil, fmt.Errorf("nur der Eigentümer kann Ordner-Freigaben verwalten")
}
cleanType := strings.TrimSpace(targetType)
@@ -3352,9 +3402,8 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde
}
}
- if permission != "read" && permission != "write" {
- permission = "read"
- }
+ // Geteilte Ordner können ausschließlich mit Leserberechtigung geteilt werden (wie Dokumente)
+ permission = "read"
var targetID, targetName, email, avatarURL string
if cleanType == "team" {
@@ -4090,6 +4139,141 @@ func (s *Store) ToggleListItem(itemID string, isCompleted bool, userID, userName
return &it, nil
}
+func (s *Store) UpdateListItem(itemID string, content *string, category *string, quantity *string) (*domain.ListItem, error) {
+ now := domain.NowISO()
+ var it domain.ListItem
+ var dInt int
+ err := s.db.QueryRow(
+ `SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
+ FROM list_items WHERE id = ?`,
+ itemID,
+ ).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
+ if err != nil {
+ return nil, err
+ }
+ it.IsCompleted = dInt != 0
+
+ if content != nil {
+ it.Content = *content
+ }
+ if category != nil {
+ it.Category = *category
+ }
+ if quantity != nil {
+ it.Quantity = *quantity
+ }
+
+ _, err = s.db.Exec(`UPDATE list_items SET content = ?, category = ?, quantity = ? WHERE id = ?`, it.Content, it.Category, it.Quantity, itemID)
+ if err != nil {
+ return nil, err
+ }
+ _, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID)
+ return &it, nil
+}
+
+func (s *Store) CreateTaskFromDocBlock(userID, docID, blockID, content string, checked bool) (*domain.Thought, error) {
+ doc, err := s.GetDocumentByID(userID, docID)
+ if err != nil {
+ return nil, err
+ }
+ t, err := s.CreateThoughtWithDetails(userID, content, "normal", "manual", "", "", "todo", "")
+ if err != nil {
+ return nil, err
+ }
+ t.ConvertedType = "document_block"
+ t.ConvertedRefID = docID + ":" + blockID
+ t.DocID = docID
+ t.BlockID = blockID
+ t.IsCompleted = checked
+ doneInt := 0
+ if checked {
+ doneInt = 1
+ t.KanbanStatus = "done"
+ }
+ _, _ = s.db.Exec(`UPDATE thoughts SET converted_type = 'document_block', converted_ref_id = ?, is_completed = ?, kanban_status = ? WHERE id = ?`,
+ t.ConvertedRefID, doneInt, t.KanbanStatus, t.ID)
+
+ var updatedBlocks []domain.DocumentBlock
+ found := false
+ for _, b := range doc.Blocks {
+ if b.ID == blockID {
+ b.TaskID = t.ID
+ b.Checked = checked
+ found = true
+ }
+ updatedBlocks = append(updatedBlocks, b)
+ }
+ if found {
+ _, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil)
+ }
+ return t, nil
+}
+
+func (s *Store) SyncDocBlockAndTask(userID, taskID string, isCompleted bool) {
+ t, err := s.GetThoughtByID(userID, taskID)
+ if err != nil || t == nil || t.ConvertedType != "document_block" || !strings.Contains(t.ConvertedRefID, ":") {
+ return
+ }
+ parts := strings.SplitN(t.ConvertedRefID, ":", 2)
+ docID, blockID := parts[0], parts[1]
+ doc, err := s.GetDocumentByID(userID, docID)
+ if err != nil || doc == nil {
+ return
+ }
+ var updatedBlocks []domain.DocumentBlock
+ found := false
+ for _, b := range doc.Blocks {
+ if b.ID == blockID {
+ if b.Checked != isCompleted {
+ b.Checked = isCompleted
+ found = true
+ }
+ }
+ updatedBlocks = append(updatedBlocks, b)
+ }
+ if found {
+ _, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil)
+ }
+}
+
+func (s *Store) UnlinkTaskFromDocBlock(userID, taskID string) {
+ t, err := s.GetThoughtByID(userID, taskID)
+ if err != nil || t == nil || t.ConvertedType != "document_block" || !strings.Contains(t.ConvertedRefID, ":") {
+ return
+ }
+ parts := strings.SplitN(t.ConvertedRefID, ":", 2)
+ docID, blockID := parts[0], parts[1]
+ doc, err := s.GetDocumentByID(userID, docID)
+ if err != nil || doc == nil {
+ return
+ }
+ var updatedBlocks []domain.DocumentBlock
+ found := false
+ for _, b := range doc.Blocks {
+ if b.ID == blockID {
+ b.TaskID = ""
+ found = true
+ }
+ updatedBlocks = append(updatedBlocks, b)
+ }
+ if found {
+ _, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil)
+ }
+}
+
+func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
+ if taskID == "" {
+ return
+ }
+ doneInt := 0
+ kanban := "todo"
+ if checked {
+ doneInt = 1
+ kanban = "done"
+ }
+ _, _ = s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ? WHERE id = ? AND user_id = ?`, doneInt, kanban, taskID, userID)
+}
+
func (s *Store) DeleteListItem(itemID string) (string, error) {
var listID string
_ = s.db.QueryRow(`SELECT list_id FROM list_items WHERE id = ?`, itemID).Scan(&listID)