From 47be39d5bf3867c9f64ba6c57b15c9723c1cc431 Mon Sep 17 00:00:00 2001 From: Mark Rai Date: Tue, 31 Mar 2026 15:38:12 -0400 Subject: [PATCH] enhancement: lane color change capability post creation Signed-off-by: Mark Rai --- internal/httpapi/routing.go | 18 ++- internal/httpapi/server.go | 2 +- internal/httpapi/server_test.go | 37 ++++-- internal/httpapi/web/dist/dialogs/settings.js | 57 ++++++-- .../httpapi/web/modules/dialogs/settings.ts | 58 +++++++-- internal/store/workflow_rename_test.go | 8 +- internal/store/workflow_update_test.go | 123 ++++++++++++++++++ internal/store/workflows.go | 28 +++- internal/version/version.go | 2 +- 9 files changed, 277 insertions(+), 56 deletions(-) create mode 100644 internal/store/workflow_update_test.go diff --git a/internal/httpapi/routing.go b/internal/httpapi/routing.go index c174d67..1884b2d 100644 --- a/internal/httpapi/routing.go +++ b/internal/httpapi/routing.go @@ -1115,7 +1115,7 @@ func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request, rest []stri return } - // PATCH /api/board/{slug}/workflow/{key} - rename workflow lane label only. + // PATCH /api/board/{slug}/workflow/{key} - update workflow lane label and color. if len(rest) == 3 && rest[1] == "workflow" && r.Method == http.MethodPatch { ctx := s.requestContext(r) userID, ok := store.UserIDFromContext(ctx) @@ -1135,12 +1135,14 @@ func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request, rest []stri } var in struct { - Name string `json:"name"` + Name string `json:"name"` + Color string `json:"color"` } if err := readJSON(w, r, s.maxBody, &in); err != nil { return } in.Name = strings.TrimSpace(in.Name) + in.Color = strings.TrimSpace(in.Color) if in.Name == "" { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "name required", map[string]any{"field": "name"}) return @@ -1149,11 +1151,19 @@ func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request, rest []stri writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "invalid workflow column name", map[string]any{"field": "name"}) return } - if err := s.store.UpdateWorkflowColumnName(ctx, project.ID, columnKey, in.Name); err != nil { + if in.Color == "" { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "color required", map[string]any{"field": "color"}) + return + } + if !store.ValidWorkflowColumnColor(in.Color) { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "invalid workflow column color", map[string]any{"field": "color"}) + return + } + if err := s.store.UpdateWorkflowColumn(ctx, project.ID, columnKey, in.Name, in.Color); err != nil { writeStoreErr(w, err, true) return } - s.emitRefreshNeeded(project.ID, "workflow_column_renamed") + s.emitRefreshNeeded(project.ID, "workflow_column_updated") w.WriteHeader(http.StatusNoContent) return } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index c90e798..1366309 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -81,7 +81,7 @@ type storeAPI interface { UpdateProjectDefaultSprintWeeks(ctx context.Context, projectID int64, userID int64, weeks int) error AddWorkflowColumn(ctx context.Context, projectID int64, name string) (store.WorkflowColumn, error) DeleteWorkflowColumn(ctx context.Context, projectID int64, key string) error - UpdateWorkflowColumnName(ctx context.Context, projectID int64, key, newName string) error + UpdateWorkflowColumn(ctx context.Context, projectID int64, key, name, color string) error GetProjectRole(ctx context.Context, projectID int64, userID int64) (store.ProjectRole, error) CheckProjectRole(ctx context.Context, projectID int64, userID int64, requiredRole store.ProjectRole) error ListProjectMembers(ctx context.Context, projectID int64, userID int64) ([]store.ProjectMember, error) diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index 6f865ed..3940b6d 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -224,7 +224,8 @@ func TestRenameLane_RequiresMaintainer(t *testing.T) { loginUserClient(t, contributorClient, ts.URL, "contrib@example.com", "password123") resp, body := doJSON(t, contributorClient, http.MethodPatch, ts.URL+"/api/board/"+project.Slug+"/workflow/"+store.DefaultColumnDoing, map[string]any{ - "name": "Working", + "name": "Working", + "color": "#10B981", }, nil) if resp.StatusCode != http.StatusForbidden { t.Fatalf("expected 403, got %d body=%s", resp.StatusCode, string(body)) @@ -246,7 +247,8 @@ func TestRenameLane_NonexistentKeyReturns404(t *testing.T) { } resp, body := doJSON(t, ownerClient, http.MethodPatch, ts.URL+"/api/board/"+project.Slug+"/workflow/not_a_lane", map[string]any{ - "name": "Working", + "name": "Working", + "color": "#10B981", }, nil) if resp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404, got %d body=%s", resp.StatusCode, string(body)) @@ -273,23 +275,31 @@ func TestRenameLane_EmptyNameRejected(t *testing.T) { }{ { name: "WhitespaceOnly", - body: map[string]any{"name": " "}, + body: map[string]any{"name": " ", "color": "#10B981"}, }, { - name: "RejectsColor", - body: map[string]any{"name": "Working", "color": "#123456"}, + name: "EmptyColor", + body: map[string]any{"name": "Working", "color": ""}, + }, + { + name: "MissingColor", + body: map[string]any{"name": "Working"}, + }, + { + name: "InvalidColor", + body: map[string]any{"name": "Working", "color": "#gggggg"}, }, { name: "RejectsKey", - body: map[string]any{"name": "Working", "key": "other"}, + body: map[string]any{"name": "Working", "color": "#10B981", "key": "other"}, }, { name: "RejectsIsDone", - body: map[string]any{"name": "Working", "isDone": true}, + body: map[string]any{"name": "Working", "color": "#10B981", "isDone": true}, }, { name: "RejectsPosition", - body: map[string]any{"name": "Working", "position": 1}, + body: map[string]any{"name": "Working", "color": "#10B981", "position": 1}, }, } for _, tc := range tests { @@ -317,7 +327,8 @@ func TestRenameLane_BoardAPIReflectsNewName(t *testing.T) { } resp, body := doJSON(t, ownerClient, http.MethodPatch, ts.URL+"/api/board/"+project.Slug+"/workflow/"+store.DefaultColumnDoing, map[string]any{ - "name": "Working", + "name": "Working", + "color": "#aabbcc", }, nil) if resp.StatusCode != http.StatusNoContent { t.Fatalf("rename lane status=%d body=%s", resp.StatusCode, string(body)) @@ -325,8 +336,9 @@ func TestRenameLane_BoardAPIReflectsNewName(t *testing.T) { var board struct { ColumnOrder []struct { - Key string `json:"key"` - Name string `json:"name"` + Key string `json:"key"` + Name string `json:"name"` + Color string `json:"color"` } `json:"columnOrder"` } resp, body = doJSON(t, ownerClient, http.MethodGet, ts.URL+"/api/board/"+project.Slug, nil, &board) @@ -338,6 +350,9 @@ func TestRenameLane_BoardAPIReflectsNewName(t *testing.T) { if lane.Name != "Working" { t.Fatalf("expected lane name %q, got %q", "Working", lane.Name) } + if lane.Color != "#aabbcc" { + t.Fatalf("expected lane color %q, got %q", "#aabbcc", lane.Color) + } return } } diff --git a/internal/httpapi/web/dist/dialogs/settings.js b/internal/httpapi/web/dist/dialogs/settings.js index 84971d8..005f794 100644 --- a/internal/httpapi/web/dist/dialogs/settings.js +++ b/internal/httpapi/web/dist/dialogs/settings.js @@ -575,6 +575,11 @@ function msToDateTimeLocalStr(ms) { const mm = String(d.getMinutes()).padStart(2, "0"); return `${y}-${m}-${day}T${hh}:${mm}`; } +const DEFAULT_WORKFLOW_LANE_COLOR = "#64748b"; +function normalizeWorkflowLaneColorForInput(color) { + const s = color?.trim(); + return s && /^#[0-9a-fA-F]{6}$/.test(s) ? s : DEFAULT_WORKFLOW_LANE_COLOR; +} function renderWorkflowTabContent() { const board = getBoard(); const workflow = board?.columnOrder ?? []; @@ -588,7 +593,7 @@ function renderWorkflowTabContent() { return `
Workflow
-
Rename lane labels or add a non-done lane inserted immediately before the done lane (whatever its label). Keys stay immutable.
+
Rename lane labels and colors, or add a non-done lane inserted immediately before the done lane (whatever its label). Keys stay immutable.
@@ -647,32 +660,35 @@ async function addWorkflowLane(name) { showToast(err.message || "Failed to add lane"); } } -async function renameWorkflowLaneLabel(key, name) { +async function updateWorkflowLane(key, payload) { const slug = getSlug(); if (!slug) { showToast("No project available"); return; } - const trimmed = name.trim(); + const trimmed = payload.name.trim(); if (!trimmed) { showToast("Lane name is required"); return; } - const currentName = getBoard()?.columnOrder?.find((lane) => lane.key === key)?.name?.trim(); - if (currentName === trimmed) + const color = payload.color.trim(); + const lane = getBoard()?.columnOrder?.find((l) => l.key === key); + const prevColor = (lane?.color ?? DEFAULT_WORKFLOW_LANE_COLOR).toLowerCase(); + if (trimmed === lane?.name?.trim() && color.toLowerCase() === prevColor) { return; + } try { recordLocalMutation(); await apiFetch(`/api/board/${slug}/workflow/${encodeURIComponent(key)}`, { method: "PATCH", - body: JSON.stringify({ name: trimmed }), + body: JSON.stringify({ name: trimmed, color }), }); await invalidateBoard(slug, getTag(), getSearch(), getSprintIdFromUrl()); await renderSettingsModal(); - showToast("Lane label updated"); + showToast("Lane updated"); } catch (err) { - showToast(err.message || "Failed to update lane label"); + showToast(err.message || "Failed to update lane"); } } async function deleteWorkflowLane(key) { @@ -1341,18 +1357,20 @@ export async function renderSettingsModal(options) { addLane(); }, { signal }); } - const bindRename = (key) => { - const input = document.querySelector(`[data-workflow-name="${key}"]`); - if (!input) + const saveWorkflowLane = (key) => { + const row = document.querySelector(`[data-workflow-key="${key}"]`); + const nameInput = row?.querySelector("[data-workflow-name]"); + const colorInput = row?.querySelector("[data-workflow-color]"); + if (!nameInput || !colorInput) return; - renameWorkflowLaneLabel(key, input.value); + updateWorkflowLane(key, { name: nameInput.value, color: colorInput.value }); }; document.querySelectorAll("[data-workflow-save]").forEach((btn) => { btn.addEventListener("click", () => { const key = btn.getAttribute("data-workflow-save"); if (!key) return; - bindRename(key); + saveWorkflowLane(key); }, { signal }); }); document.querySelectorAll("[data-workflow-name]").forEach((inputEl) => { @@ -1363,7 +1381,18 @@ export async function renderSettingsModal(options) { const key = inputEl.getAttribute("data-workflow-name"); if (!key) return; - bindRename(key); + saveWorkflowLane(key); + }, { signal }); + }); + document.querySelectorAll("[data-workflow-color]").forEach((colorEl) => { + colorEl.addEventListener("keydown", (e) => { + if (e.key !== "Enter") + return; + e.preventDefault(); + const key = colorEl.getAttribute("data-workflow-color"); + if (!key) + return; + saveWorkflowLane(key); }, { signal }); }); document.querySelectorAll("[data-workflow-delete]").forEach((btn) => { diff --git a/internal/httpapi/web/modules/dialogs/settings.ts b/internal/httpapi/web/modules/dialogs/settings.ts index d254886..84f308d 100644 --- a/internal/httpapi/web/modules/dialogs/settings.ts +++ b/internal/httpapi/web/modules/dialogs/settings.ts @@ -656,6 +656,13 @@ function msToDateTimeLocalStr(ms: number): string { return `${y}-${m}-${day}T${hh}:${mm}`; } +const DEFAULT_WORKFLOW_LANE_COLOR = "#64748b"; + +function normalizeWorkflowLaneColorForInput(color: string | undefined | null): string { + const s = color?.trim(); + return s && /^#[0-9a-fA-F]{6}$/.test(s) ? s : DEFAULT_WORKFLOW_LANE_COLOR; +} + function renderWorkflowTabContent(): string { const board = getBoard(); const workflow = board?.columnOrder ?? []; @@ -669,7 +676,7 @@ function renderWorkflowTabContent(): string { return `
Workflow
-
Rename lane labels or add a non-done lane inserted immediately before the done lane (whatever its label). Keys stay immutable.
+
Rename lane labels and colors, or add a non-done lane inserted immediately before the done lane (whatever its label). Keys stay immutable.
@@ -729,30 +744,34 @@ async function addWorkflowLane(name: string): Promise { } } -async function renameWorkflowLaneLabel(key: string, name: string): Promise { +async function updateWorkflowLane(key: string, payload: { name: string; color: string }): Promise { const slug = getSlug(); if (!slug) { showToast("No project available"); return; } - const trimmed = name.trim(); + const trimmed = payload.name.trim(); if (!trimmed) { showToast("Lane name is required"); return; } - const currentName = getBoard()?.columnOrder?.find((lane) => lane.key === key)?.name?.trim(); - if (currentName === trimmed) return; + const color = payload.color.trim(); + const lane = getBoard()?.columnOrder?.find((l) => l.key === key); + const prevColor = (lane?.color ?? DEFAULT_WORKFLOW_LANE_COLOR).toLowerCase(); + if (trimmed === lane?.name?.trim() && color.toLowerCase() === prevColor) { + return; + } try { recordLocalMutation(); await apiFetch(`/api/board/${slug}/workflow/${encodeURIComponent(key)}`, { method: "PATCH", - body: JSON.stringify({ name: trimmed }), + body: JSON.stringify({ name: trimmed, color }), }); await invalidateBoard(slug, getTag(), getSearch(), getSprintIdFromUrl()); await renderSettingsModal(); - showToast("Lane label updated"); + showToast("Lane updated"); } catch (err: any) { - showToast(err.message || "Failed to update lane label"); + showToast(err.message || "Failed to update lane"); } } @@ -1440,16 +1459,18 @@ export async function renderSettingsModal(options?: { skipProfileRefetch?: boole addLane(); }, { signal }); } - const bindRename = (key: string) => { - const input = document.querySelector(`[data-workflow-name="${key}"]`) as HTMLInputElement | null; - if (!input) return; - renameWorkflowLaneLabel(key, input.value); + const saveWorkflowLane = (key: string) => { + const row = document.querySelector(`[data-workflow-key="${key}"]`) as HTMLElement | null; + const nameInput = row?.querySelector("[data-workflow-name]") as HTMLInputElement | null; + const colorInput = row?.querySelector("[data-workflow-color]") as HTMLInputElement | null; + if (!nameInput || !colorInput) return; + updateWorkflowLane(key, { name: nameInput.value, color: colorInput.value }); }; document.querySelectorAll("[data-workflow-save]").forEach((btn) => { btn.addEventListener("click", () => { const key = (btn as HTMLElement).getAttribute("data-workflow-save"); if (!key) return; - bindRename(key); + saveWorkflowLane(key); }, { signal }); }); document.querySelectorAll("[data-workflow-name]").forEach((inputEl) => { @@ -1458,7 +1479,16 @@ export async function renderSettingsModal(options?: { skipProfileRefetch?: boole e.preventDefault(); const key = (inputEl as HTMLElement).getAttribute("data-workflow-name"); if (!key) return; - bindRename(key); + saveWorkflowLane(key); + }, { signal }); + }); + document.querySelectorAll("[data-workflow-color]").forEach((colorEl) => { + colorEl.addEventListener("keydown", (e) => { + if ((e as KeyboardEvent).key !== "Enter") return; + e.preventDefault(); + const key = (colorEl as HTMLElement).getAttribute("data-workflow-color"); + if (!key) return; + saveWorkflowLane(key); }, { signal }); }); document.querySelectorAll("[data-workflow-delete]").forEach((btn) => { diff --git a/internal/store/workflow_rename_test.go b/internal/store/workflow_rename_test.go index d8d0480..c249d81 100644 --- a/internal/store/workflow_rename_test.go +++ b/internal/store/workflow_rename_test.go @@ -23,8 +23,8 @@ func TestRenameLane_UpdatesNamePreservesKey(t *testing.T) { t.Fatalf("CreateTodo: %v", err) } - if err := st.UpdateWorkflowColumnName(ctx, project.ID, DefaultColumnDoing, "Working"); err != nil { - t.Fatalf("UpdateWorkflowColumnName: %v", err) + if err := st.UpdateWorkflowColumn(ctx, project.ID, DefaultColumnDoing, "Working", "#10B981"); err != nil { + t.Fatalf("UpdateWorkflowColumn: %v", err) } workflow, err := st.GetProjectWorkflow(ctx, project.ID) @@ -91,8 +91,8 @@ func TestRenameLane_BurndownUnaffected(t *testing.T) { if err != nil { t.Fatalf("GetBacklogSize before rename: %v", err) } - if err := st.UpdateWorkflowColumnName(ctx, project.ID, DefaultColumnDone, "Shipped"); err != nil { - t.Fatalf("UpdateWorkflowColumnName: %v", err) + if err := st.UpdateWorkflowColumn(ctx, project.ID, DefaultColumnDone, "Shipped", "#EF4444"); err != nil { + t.Fatalf("UpdateWorkflowColumn: %v", err) } after, err := st.GetBacklogSize(ctx, project.ID, ModeFull) if err != nil { diff --git a/internal/store/workflow_update_test.go b/internal/store/workflow_update_test.go new file mode 100644 index 0000000..2bbde5c --- /dev/null +++ b/internal/store/workflow_update_test.go @@ -0,0 +1,123 @@ +package store + +import ( + "context" + "errors" + "testing" +) + +func TestUpdateWorkflowColumn_ValidNameAndColor(t *testing.T) { + st, cleanup := newTestStore(t) + defer cleanup() + + ctx := context.Background() + project, err := st.CreateProject(ctx, "workflow-update") + if err != nil { + t.Fatalf("CreateProject: %v", err) + } + if err := st.UpdateWorkflowColumn(ctx, project.ID, DefaultColumnDoing, "Working", "#112233"); err != nil { + t.Fatalf("UpdateWorkflowColumn: %v", err) + } + workflow, err := st.GetProjectWorkflow(ctx, project.ID) + if err != nil { + t.Fatalf("GetProjectWorkflow: %v", err) + } + var col *WorkflowColumn + for i := range workflow { + if workflow[i].Key == DefaultColumnDoing { + col = &workflow[i] + break + } + } + if col == nil { + t.Fatalf("expected column %q", DefaultColumnDoing) + } + if col.Name != "Working" || col.Color != "#112233" { + t.Fatalf("got name=%q color=%q", col.Name, col.Color) + } +} + +func TestUpdateWorkflowColumn_InvalidColorRejected(t *testing.T) { + st, cleanup := newTestStore(t) + defer cleanup() + + ctx := context.Background() + project, err := st.CreateProject(ctx, "workflow-bad-color") + if err != nil { + t.Fatalf("CreateProject: %v", err) + } + err = st.UpdateWorkflowColumn(ctx, project.ID, DefaultColumnDoing, "Working", "#gggggg") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrValidation) { + t.Fatalf("expected ErrValidation, got %v", err) + } + err = st.UpdateWorkflowColumn(ctx, project.ID, DefaultColumnDoing, "Working", "red") + if err == nil { + t.Fatal("expected error for non-hex color") + } + if !errors.Is(err, ErrValidation) { + t.Fatalf("expected ErrValidation, got %v", err) + } +} + +func TestUpdateWorkflowColumn_NonexistentKey(t *testing.T) { + st, cleanup := newTestStore(t) + defer cleanup() + + ctx := context.Background() + project, err := st.CreateProject(ctx, "workflow-missing-key") + if err != nil { + t.Fatalf("CreateProject: %v", err) + } + err = st.UpdateWorkflowColumn(ctx, project.ID, "no_such_lane", "X", "#112233") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestUpdateWorkflowColumn_OtherFieldsUnchanged(t *testing.T) { + st, cleanup := newTestStore(t) + defer cleanup() + + ctx := context.Background() + project, err := st.CreateProject(ctx, "workflow-fields") + if err != nil { + t.Fatalf("CreateProject: %v", err) + } + before, err := st.GetProjectWorkflow(ctx, project.ID) + if err != nil { + t.Fatalf("GetProjectWorkflow: %v", err) + } + var doing WorkflowColumn + for i := range before { + if before[i].Key == DefaultColumnDoing { + doing = before[i] + break + } + } + if err := st.UpdateWorkflowColumn(ctx, project.ID, DefaultColumnDoing, "Renamed", "#aabbcc"); err != nil { + t.Fatalf("UpdateWorkflowColumn: %v", err) + } + after, err := st.GetProjectWorkflow(ctx, project.ID) + if err != nil { + t.Fatalf("GetProjectWorkflow: %v", err) + } + var got WorkflowColumn + for i := range after { + if after[i].Key == DefaultColumnDoing { + got = after[i] + break + } + } + if got.Key != doing.Key || got.Position != doing.Position || got.IsDone != doing.IsDone || got.System != doing.System { + t.Fatalf("immutable fields changed: before=%+v after=%+v", doing, got) + } + if got.Name != "Renamed" || got.Color != "#aabbcc" { + t.Fatalf("expected name+color updated, got %+v", got) + } +} diff --git a/internal/store/workflows.go b/internal/store/workflows.go index ae34145..b3476bf 100644 --- a/internal/store/workflows.go +++ b/internal/store/workflows.go @@ -11,6 +11,12 @@ import ( var columnKeyRe = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9_]*[a-z0-9])?$`) var colorHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) +// ValidWorkflowColumnColor reports whether s is a non-empty #RRGGBB hex string. +func ValidWorkflowColumnColor(s string) bool { + s = strings.TrimSpace(s) + return s != "" && colorHexRe.MatchString(s) +} + const ( maxWorkflowColumns = 12 defaultWorkflowColor = "#64748b" @@ -301,22 +307,30 @@ LIMIT 1`, projectID).Scan(&key); err != nil { return key, nil } -func (s *Store) UpdateWorkflowColumnName(ctx context.Context, projectID int64, key, newName string) error { +// UpdateWorkflowColumn sets the display name and color for a workflow lane. Key, position, is_done, and system are unchanged. +func (s *Store) UpdateWorkflowColumn(ctx context.Context, projectID int64, key, name, color string) error { key = strings.TrimSpace(key) - newName = strings.TrimSpace(newName) - if newName == "" || len(newName) > maxWorkflowNameLength { + if key == "" { + return fmt.Errorf("%w: invalid workflow column key", ErrValidation) + } + name = strings.TrimSpace(name) + color = strings.TrimSpace(color) + if name == "" || len(name) > maxWorkflowNameLength { return fmt.Errorf("%w: invalid workflow column name", ErrValidation) } + if color == "" || !colorHexRe.MatchString(color) { + return fmt.Errorf("%w: invalid workflow column color", ErrValidation) + } res, err := s.db.ExecContext(ctx, ` UPDATE project_workflow_columns -SET name = ? -WHERE project_id = ? AND key = ?`, newName, projectID, key) +SET name = ?, color = ? +WHERE project_id = ? AND key = ?`, name, color, projectID, key) if err != nil { - return fmt.Errorf("update workflow column name: %w", err) + return fmt.Errorf("update workflow column: %w", err) } rowsAffected, err := res.RowsAffected() if err != nil { - return fmt.Errorf("rows affected update workflow column name: %w", err) + return fmt.Errorf("rows affected update workflow column: %w", err) } if rowsAffected == 0 { return ErrNotFound diff --git a/internal/version/version.go b/internal/version/version.go index 28deb91..24e3729 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -8,7 +8,7 @@ package version // // Convention: Update when releasing (e.g., "1.0.0", "1.1.0"); match git tags // (e.g., tag "v1.0.0" should have Version = "1.0.0"). -const Version = "3.6.0" +const Version = "3.6.1" // ExportFormatVersion is the version of the backup/export data format. // Only increment this when the ExportData structure changes in a breaking way.