mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e50bc9e42 | ||
|
|
53aa781692 | ||
|
|
e34abda709 |
+6
-3
@@ -128,7 +128,7 @@ export default [ToolName] as ToolComponent;
|
||||
## 3. Register Tool in System
|
||||
Update these files to register your new tool:
|
||||
|
||||
**Tool Registry** (`frontend/editor/src/data/useTranslatedToolRegistry.tsx`):
|
||||
**Tool Registry** (`frontend/editor/src/core/data/useTranslatedToolRegistry.tsx`):
|
||||
1. Add imports at the top:
|
||||
```typescript
|
||||
import [ToolName] from "../tools/[ToolName]";
|
||||
@@ -147,11 +147,14 @@ import [ToolName]Settings from "../components/tools/[toolName]/[ToolName]Setting
|
||||
subcategoryId: SubcategoryId.APPROPRIATE_SUBCATEGORY,
|
||||
maxFiles: -1, // or specific number
|
||||
endpoints: ["endpoint-name"],
|
||||
operationConfig: [toolName]OperationConfig,
|
||||
settingsComponent: [ToolName]Settings, // if settings exist
|
||||
operationConfig: asRegistryConfig([toolName]OperationConfig),
|
||||
automationSettings: [ToolName]Settings, // or null if no automation settings
|
||||
newInVersion: "2.16.0", // release the tool first ships in
|
||||
},
|
||||
```
|
||||
|
||||
**"New"/"Updated" badges**: set `newInVersion` to the release the tool first ships in so the tool list shows a "New" badge; use `updatedInVersion` when an existing tool gets a major revamp to show "Updated". Badges expire automatically once that release is more than one minor behind the running app, and per user once they open the tool (see `frontend/editor/src/core/utils/toolFreshness.ts`).
|
||||
|
||||
## 4. Add Tooltips (Optional but Recommended)
|
||||
Create user-friendly tooltips to help non-technical users understand your tool. **Use simple, clear language - avoid technical jargon:**
|
||||
|
||||
|
||||
@@ -11274,13 +11274,16 @@ ZIP = "a ZIP archive"
|
||||
[toolPanel]
|
||||
alpha = "Alpha"
|
||||
backToAllTools = "Back to all tools"
|
||||
beta = "Beta"
|
||||
collapse = "Collapse panel"
|
||||
expand = "Expand panel"
|
||||
goBack = "Go back"
|
||||
new = "New"
|
||||
pdfTools = "PDF Tools"
|
||||
placeholder = "Choose a tool to get started"
|
||||
premiumFeature = "Premium feature:"
|
||||
toolsHeader = "Tools"
|
||||
updated = "Updated"
|
||||
viewAllTools = "View all tools"
|
||||
|
||||
[toolPanel.fullscreen]
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from "react";
|
||||
import { Text, Badge } from "@mantine/core";
|
||||
import { Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay";
|
||||
import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar";
|
||||
import ToolStatusBadges from "@app/components/tools/shared/ToolStatusBadges";
|
||||
import {
|
||||
ToolRegistryEntry,
|
||||
getSubcategoryColor,
|
||||
@@ -82,11 +83,7 @@ const CompactToolItem: React.FC<CompactToolItemProps> = ({
|
||||
<span className="tool-panel__fullscreen-list-body">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<Text className="tool-panel__fullscreen-name">{tool.name}</Text>
|
||||
{tool.versionStatus === "alpha" && (
|
||||
<Badge size="xs" variant="light" color="orange">
|
||||
{t("toolPanel.alpha", "Alpha")}
|
||||
</Badge>
|
||||
)}
|
||||
<ToolStatusBadges toolId={id} tool={tool} />
|
||||
</div>
|
||||
</span>
|
||||
{!disabled && (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from "react";
|
||||
import { Text, Badge } from "@mantine/core";
|
||||
import { Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay";
|
||||
import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar";
|
||||
import ToolStatusBadges from "@app/components/tools/shared/ToolStatusBadges";
|
||||
import {
|
||||
ToolRegistryEntry,
|
||||
getSubcategoryColor,
|
||||
@@ -80,12 +81,7 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({
|
||||
<span className="tool-panel__fullscreen-body">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<Text className="tool-panel__fullscreen-name">{tool.name}</Text>
|
||||
{tool.versionStatus === "alpha" && (
|
||||
<Badge size="xs" variant="light" color="orange">
|
||||
{/* we can add more translations for different badges in future, like beta, etc. */}
|
||||
{t("toolPanel.alpha", "Alpha")}
|
||||
</Badge>
|
||||
)}
|
||||
<ToolStatusBadges toolId={id} tool={tool} />
|
||||
</div>
|
||||
<Text
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Badge } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import { useToolFreshnessBadge } from "@app/hooks/tools/useToolFreshnessBadge";
|
||||
|
||||
interface ToolStatusBadgesProps {
|
||||
toolId: string;
|
||||
tool: ToolRegistryEntry;
|
||||
// Matches the 0.25 opacity tool rows use when visually unavailable.
|
||||
dimmed?: boolean;
|
||||
}
|
||||
|
||||
/** Status chips shown next to a tool's name: Alpha/Beta plus New/Updated. */
|
||||
const ToolStatusBadges = ({
|
||||
toolId,
|
||||
tool,
|
||||
dimmed = false,
|
||||
}: ToolStatusBadgesProps) => {
|
||||
const { t } = useTranslation();
|
||||
const freshness = useToolFreshnessBadge(toolId, tool);
|
||||
|
||||
const badges: { key: string; color: string; label: string }[] = [];
|
||||
if (tool.versionStatus === "alpha") {
|
||||
badges.push({
|
||||
key: "alpha",
|
||||
color: "orange",
|
||||
label: t("toolPanel.alpha", "Alpha"),
|
||||
});
|
||||
} else if (tool.versionStatus === "beta") {
|
||||
badges.push({
|
||||
key: "beta",
|
||||
color: "orange",
|
||||
label: t("toolPanel.beta", "Beta"),
|
||||
});
|
||||
}
|
||||
if (freshness === "new") {
|
||||
badges.push({
|
||||
key: "new",
|
||||
color: "teal",
|
||||
label: t("toolPanel.new", "New"),
|
||||
});
|
||||
} else if (freshness === "updated") {
|
||||
badges.push({
|
||||
key: "updated",
|
||||
color: "blue",
|
||||
label: t("toolPanel.updated", "Updated"),
|
||||
});
|
||||
}
|
||||
if (badges.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{badges.map(({ key, color, label }) => (
|
||||
<Badge
|
||||
key={key}
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={color}
|
||||
style={{ flexShrink: 0, opacity: dimmed ? 0.25 : 1 }}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolStatusBadges;
|
||||
@@ -11,6 +11,7 @@ import FitText from "@app/components/shared/FitText";
|
||||
import { useHotkeys } from "@app/contexts/HotkeyContext";
|
||||
import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay";
|
||||
import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar";
|
||||
import ToolStatusBadges from "@app/components/tools/shared/ToolStatusBadges";
|
||||
import {
|
||||
useToolWorkflowActions,
|
||||
useToolWorkflowData,
|
||||
@@ -176,16 +177,11 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
opacity: visuallyUnavailable ? 0.25 : 1,
|
||||
}}
|
||||
/>
|
||||
{tool.versionStatus === "alpha" && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
style={{ flexShrink: 0, opacity: visuallyUnavailable ? 0.25 : 1 }}
|
||||
>
|
||||
{t("toolPanel.alpha", "Alpha")}
|
||||
</Badge>
|
||||
)}
|
||||
<ToolStatusBadges
|
||||
toolId={id}
|
||||
tool={tool}
|
||||
dimmed={visuallyUnavailable}
|
||||
/>
|
||||
{typeof badgeCount === "number" && badgeCount > 0 && (
|
||||
<Badge
|
||||
size="sm"
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useNavigationUrlSync } from "@app/hooks/useUrlSync";
|
||||
import { stripBasePath } from "@app/constants/app";
|
||||
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
|
||||
import { filterToolRegistryByQuery } from "@app/utils/toolSearch";
|
||||
import { acknowledgeToolFreshness } from "@app/utils/toolFreshness";
|
||||
import { useToolHistory } from "@app/hooks/tools/useUserToolActivity";
|
||||
import {
|
||||
ToolWorkflowState,
|
||||
@@ -434,6 +435,14 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
setLeftPanelView,
|
||||
]);
|
||||
|
||||
// Opening a tool counts as seeing it: clear its New/Updated badge.
|
||||
useEffect(() => {
|
||||
const toolId = navigationState.selectedTool;
|
||||
if (!toolId) return;
|
||||
const tool = allTools[toolId];
|
||||
if (tool) acknowledgeToolFreshness(toolId, tool);
|
||||
}, [navigationState.selectedTool, allTools]);
|
||||
|
||||
// Tool reset methods
|
||||
const registerToolReset = useCallback(
|
||||
(toolId: string, resetFunction: () => void) => {
|
||||
|
||||
@@ -78,6 +78,10 @@ export type ToolRegistryEntry = {
|
||||
synonyms?: string[];
|
||||
// Version status indicator (e.g., "alpha", "beta")
|
||||
versionStatus?: "alpha" | "beta";
|
||||
// Release that introduced the tool; shows a "New" badge while that release is recent.
|
||||
newInVersion?: string;
|
||||
// Release of the tool's last major revamp; shows an "Updated" badge while recent.
|
||||
updatedInVersion?: string;
|
||||
// Whether this tool requires premium access
|
||||
requiresPremium?: boolean;
|
||||
};
|
||||
|
||||
@@ -239,6 +239,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
automationSettings: null,
|
||||
supportsAutomate: false,
|
||||
synonyms: getSynonyms(t, "sharedSign"),
|
||||
newInVersion: "2.14.0",
|
||||
},
|
||||
addText: {
|
||||
icon: (
|
||||
@@ -480,6 +481,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
"modify",
|
||||
"builder",
|
||||
],
|
||||
// Fill Form became a full Form Editor (create/edit/delete fields).
|
||||
updatedInVersion: "2.15.0",
|
||||
},
|
||||
changePermissions: {
|
||||
icon: <LocalIcon icon="lock-outline" width="1.5rem" height="1.5rem" />,
|
||||
@@ -677,6 +680,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
import("@app/components/tools/autoRotate/AutoRotateAutomationSettings"),
|
||||
),
|
||||
synonyms: getSynonyms(t, "autoRotate"),
|
||||
newInVersion: "2.15.0",
|
||||
},
|
||||
split: {
|
||||
icon: (
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import {
|
||||
getAcknowledgedToolVersions,
|
||||
getToolFreshness,
|
||||
isToolFreshnessAcknowledged,
|
||||
subscribeToolFreshness,
|
||||
type ToolFreshnessBadge,
|
||||
} from "@app/utils/toolFreshness";
|
||||
|
||||
// "New"/"Updated" badge for a tool; null once the tagged release is no longer
|
||||
// recent or the user already opened the tool at that version.
|
||||
export function useToolFreshnessBadge(
|
||||
toolId: string,
|
||||
tool: Pick<ToolRegistryEntry, "newInVersion" | "updatedInVersion">,
|
||||
): ToolFreshnessBadge | null {
|
||||
const { config } = useAppConfig();
|
||||
const acknowledged = useSyncExternalStore(
|
||||
subscribeToolFreshness,
|
||||
getAcknowledgedToolVersions,
|
||||
getAcknowledgedToolVersions,
|
||||
);
|
||||
const freshness = getToolFreshness(tool, config?.appVersion);
|
||||
if (!freshness) return null;
|
||||
if (isToolFreshnessAcknowledged(acknowledged, toolId, freshness.version)) {
|
||||
return null;
|
||||
}
|
||||
return freshness.badge;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* "New"/"Updated" tool badges, driven by newInVersion/updatedInVersion tags in
|
||||
* the registry compared against app-config's appVersion (see toolFreshness.ts).
|
||||
* The app version is pinned per test so these stay hermetic as releases move on:
|
||||
* autoRotate keeps newInVersion 2.15.0 forever, so at a pinned 2.15.x it is
|
||||
* always "recent" here regardless of the real current version.
|
||||
*/
|
||||
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
async function openEditorAtVersion(page: Page, appVersion: string) {
|
||||
// Last-registered route wins, so this overrides the fixture's app-config stub.
|
||||
await page.route("**/api/v1/config/app-config", (route: Route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
enableLogin: false,
|
||||
isAdmin: false,
|
||||
languages: ["en-US"],
|
||||
defaultLocale: "en-US",
|
||||
appVersion,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto("/editor", { waitUntil: "domcontentloaded" });
|
||||
}
|
||||
|
||||
const toolButton = (page: Page, toolId: string) =>
|
||||
page.locator(`[data-tour="tool-button-${toolId}"]`).first();
|
||||
const badge = (page: Page, toolId: string, label: string) =>
|
||||
toolButton(page, toolId).getByText(label, { exact: true });
|
||||
|
||||
test.describe("Tool freshness badges", () => {
|
||||
test("recent tools show New/Updated and opening a tool clears its badge", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openEditorAtVersion(page, "2.15.1");
|
||||
|
||||
// autoRotate: newInVersion 2.15.0. formFill: updatedInVersion 2.15.0.
|
||||
await expect(badge(page, "autoRotate", "New")).toBeVisible();
|
||||
await expect(badge(page, "formFill", "Updated")).toBeVisible();
|
||||
|
||||
// An untagged tool gets neither badge.
|
||||
await expect(toolButton(page, "merge")).toBeVisible();
|
||||
await expect(badge(page, "merge", "New")).toHaveCount(0);
|
||||
await expect(badge(page, "merge", "Updated")).toHaveCount(0);
|
||||
|
||||
// Opening the tool acknowledges it; back on the list the badge is gone.
|
||||
await toolButton(page, "autoRotate").click();
|
||||
// Wait for the route so the acknowledgement effect has run before navigating.
|
||||
await expect(page).toHaveURL(/\/auto-rotate(?:$|[/?#])/);
|
||||
await page.goto("/editor", { waitUntil: "domcontentloaded" });
|
||||
await expect(toolButton(page, "autoRotate")).toBeVisible();
|
||||
await expect(badge(page, "autoRotate", "New")).toHaveCount(0);
|
||||
await expect(badge(page, "formFill", "Updated")).toBeVisible();
|
||||
|
||||
// The acknowledgement is persisted, so a reload doesn't resurrect it.
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(toolButton(page, "autoRotate")).toBeVisible();
|
||||
await expect(badge(page, "autoRotate", "New")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("badges expire once the tagged release is over a minor behind", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openEditorAtVersion(page, "2.17.0");
|
||||
|
||||
await expect(toolButton(page, "autoRotate")).toBeVisible();
|
||||
await expect(badge(page, "autoRotate", "New")).toHaveCount(0);
|
||||
await expect(toolButton(page, "formFill")).toBeVisible();
|
||||
await expect(badge(page, "formFill", "Updated")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
acknowledgeToolFreshness,
|
||||
getAcknowledgedToolVersions,
|
||||
getToolFreshness,
|
||||
isRecentRelease,
|
||||
isToolFreshnessAcknowledged,
|
||||
latestTaggedVersion,
|
||||
resetToolFreshnessCache,
|
||||
} from "@app/utils/toolFreshness";
|
||||
|
||||
const STORAGE_KEY = "stirling.toolFreshness.acknowledged";
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
resetToolFreshnessCache();
|
||||
});
|
||||
|
||||
describe("isRecentRelease", () => {
|
||||
test("current and previous minor are recent", () => {
|
||||
expect(isRecentRelease("2.15.0", "2.15.2")).toBe(true);
|
||||
expect(isRecentRelease("2.14.0", "2.15.2")).toBe(true);
|
||||
});
|
||||
|
||||
test("two minors back is stale", () => {
|
||||
expect(isRecentRelease("2.13.0", "2.15.2")).toBe(false);
|
||||
});
|
||||
|
||||
test("tagged version ahead of the build is recent", () => {
|
||||
expect(isRecentRelease("2.16.0", "2.15.2")).toBe(true);
|
||||
expect(isRecentRelease("3.0.0", "2.15.2")).toBe(true);
|
||||
expect(isRecentRelease("2.15.0", "0.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("older major is stale", () => {
|
||||
expect(isRecentRelease("1.9.0", "2.0.0")).toBe(false);
|
||||
});
|
||||
|
||||
test("unknown app version hides badges", () => {
|
||||
expect(isRecentRelease("2.15.0", undefined)).toBe(false);
|
||||
expect(isRecentRelease("2.15.0", null)).toBe(false);
|
||||
expect(isRecentRelease("2.15.0", "")).toBe(false);
|
||||
expect(isRecentRelease("2.15.0", "unknown")).toBe(false);
|
||||
});
|
||||
|
||||
test("unparseable tagged version never badges", () => {
|
||||
expect(isRecentRelease("next", "2.15.0")).toBe(false);
|
||||
expect(isRecentRelease("", "2.15.0")).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts a v prefix", () => {
|
||||
expect(isRecentRelease("v2.15.0", "2.15.2")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("latestTaggedVersion", () => {
|
||||
test("picks the higher of new and updated", () => {
|
||||
expect(
|
||||
latestTaggedVersion({
|
||||
newInVersion: "2.14.0",
|
||||
updatedInVersion: "2.15.0",
|
||||
}),
|
||||
).toBe("2.15.0");
|
||||
expect(
|
||||
latestTaggedVersion({
|
||||
newInVersion: "2.15.0",
|
||||
updatedInVersion: "2.14.0",
|
||||
}),
|
||||
).toBe("2.15.0");
|
||||
expect(latestTaggedVersion({ newInVersion: "2.14.0" })).toBe("2.14.0");
|
||||
expect(latestTaggedVersion({})).toBeNull();
|
||||
});
|
||||
|
||||
test("a v prefix does not skew the comparison", () => {
|
||||
expect(
|
||||
latestTaggedVersion({
|
||||
newInVersion: "v2.15.0",
|
||||
updatedInVersion: "2.14.0",
|
||||
}),
|
||||
).toBe("v2.15.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getToolFreshness", () => {
|
||||
test("recent newInVersion shows New", () => {
|
||||
expect(getToolFreshness({ newInVersion: "2.15.0" }, "2.15.1")).toEqual({
|
||||
badge: "new",
|
||||
version: "2.15.0",
|
||||
});
|
||||
});
|
||||
|
||||
test("stale newInVersion with recent update shows Updated", () => {
|
||||
expect(
|
||||
getToolFreshness(
|
||||
{ newInVersion: "2.10.0", updatedInVersion: "2.15.0" },
|
||||
"2.15.1",
|
||||
),
|
||||
).toEqual({ badge: "updated", version: "2.15.0" });
|
||||
});
|
||||
|
||||
test("New outranks Updated and advertises the latest version", () => {
|
||||
expect(
|
||||
getToolFreshness(
|
||||
{ newInVersion: "2.14.0", updatedInVersion: "2.15.0" },
|
||||
"2.15.1",
|
||||
),
|
||||
).toEqual({ badge: "new", version: "2.15.0" });
|
||||
});
|
||||
|
||||
test("stale or missing versions produce no badge", () => {
|
||||
expect(getToolFreshness({ newInVersion: "2.10.0" }, "2.15.1")).toBeNull();
|
||||
expect(getToolFreshness({}, "2.15.1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("acknowledgements", () => {
|
||||
test("acknowledging hides that version but not a later one", () => {
|
||||
acknowledgeToolFreshness("autoRotate", { newInVersion: "2.15.0" });
|
||||
let acknowledged = getAcknowledgedToolVersions();
|
||||
expect(
|
||||
isToolFreshnessAcknowledged(acknowledged, "autoRotate", "2.15.0"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isToolFreshnessAcknowledged(acknowledged, "autoRotate", "2.16.0"),
|
||||
).toBe(false);
|
||||
|
||||
// A later update re-surfaces the badge until acknowledged again.
|
||||
acknowledgeToolFreshness("autoRotate", {
|
||||
newInVersion: "2.15.0",
|
||||
updatedInVersion: "2.16.0",
|
||||
});
|
||||
acknowledged = getAcknowledgedToolVersions();
|
||||
expect(
|
||||
isToolFreshnessAcknowledged(acknowledged, "autoRotate", "2.16.0"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("a v prefix does not hide a newer badge", () => {
|
||||
expect(
|
||||
isToolFreshnessAcknowledged(
|
||||
{ autoRotate: "2.14.0" },
|
||||
"autoRotate",
|
||||
"v2.15.0",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("persists to localStorage and survives a cache reset", () => {
|
||||
acknowledgeToolFreshness("sharedSign", { newInVersion: "2.14.0" });
|
||||
resetToolFreshnessCache();
|
||||
expect(
|
||||
isToolFreshnessAcknowledged(
|
||||
getAcknowledgedToolVersions(),
|
||||
"sharedSign",
|
||||
"2.14.0",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("untagged tools are never recorded", () => {
|
||||
acknowledgeToolFreshness("merge", {});
|
||||
expect(getAcknowledgedToolVersions()).toEqual({});
|
||||
expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
test("corrupt stored state is ignored", () => {
|
||||
window.localStorage.setItem(STORAGE_KEY, "not json");
|
||||
expect(getAcknowledgedToolVersions()).toEqual({});
|
||||
|
||||
resetToolFreshnessCache();
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(["a"]));
|
||||
expect(getAcknowledgedToolVersions()).toEqual({});
|
||||
|
||||
resetToolFreshnessCache();
|
||||
window.localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ good: "2.15.0", bad: 42 }),
|
||||
);
|
||||
expect(getAcknowledgedToolVersions()).toEqual({ good: "2.15.0" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import { updateService } from "@app/services/updateService";
|
||||
|
||||
export type ToolFreshnessBadge = "new" | "updated";
|
||||
|
||||
export interface ToolFreshnessInfo {
|
||||
badge: ToolFreshnessBadge;
|
||||
// Version the badge advertises; acknowledging it (or newer) hides the badge.
|
||||
version: string;
|
||||
}
|
||||
|
||||
type FreshnessFields = Pick<
|
||||
ToolRegistryEntry,
|
||||
"newInVersion" | "updatedInVersion"
|
||||
>;
|
||||
|
||||
// Badges expire on their own once the tagged release is this many minors behind.
|
||||
const RECENT_MINOR_WINDOW = 1;
|
||||
|
||||
const STORAGE_KEY = "stirling.toolFreshness.acknowledged";
|
||||
|
||||
function parseMajorMinor(
|
||||
version: string,
|
||||
): { major: number; minor: number } | null {
|
||||
const match = /^v?(\d+)\.(\d+)/.exec(version.trim());
|
||||
if (!match) return null;
|
||||
return { major: Number(match[1]), minor: Number(match[2]) };
|
||||
}
|
||||
|
||||
// compareVersions parses each dot-segment with parseInt, so a leading "v" reads as
|
||||
// 0; strip it so comparisons accept what parseMajorMinor already allows.
|
||||
function normalizeVersion(version: string): string {
|
||||
return version.trim().replace(/^v/i, "");
|
||||
}
|
||||
|
||||
// Recent = within RECENT_MINOR_WINDOW of the app's minor, or newer than the
|
||||
// build itself (registry tagged ahead, e.g. dev builds reporting 0.0.0).
|
||||
export function isRecentRelease(
|
||||
taggedVersion: string,
|
||||
appVersion: string | null | undefined,
|
||||
): boolean {
|
||||
const tagged = parseMajorMinor(taggedVersion);
|
||||
if (!tagged) return false;
|
||||
const current = appVersion ? parseMajorMinor(appVersion) : null;
|
||||
// Unknown app version: stay quiet. A 401 or a failed config fetch falls back to
|
||||
// a default config with no appVersion, which would pin every badge on forever.
|
||||
if (!current) return false;
|
||||
if (tagged.major !== current.major) return tagged.major > current.major;
|
||||
return current.minor - tagged.minor <= RECENT_MINOR_WINDOW;
|
||||
}
|
||||
|
||||
// Highest version the tool's registry entry is tagged with, if any.
|
||||
export function latestTaggedVersion(tool: FreshnessFields): string | null {
|
||||
const { newInVersion, updatedInVersion } = tool;
|
||||
if (newInVersion && updatedInVersion) {
|
||||
return updateService.compareVersions(
|
||||
normalizeVersion(updatedInVersion),
|
||||
normalizeVersion(newInVersion),
|
||||
) >= 0
|
||||
? updatedInVersion
|
||||
: newInVersion;
|
||||
}
|
||||
return updatedInVersion ?? newInVersion ?? null;
|
||||
}
|
||||
|
||||
// "New" outranks "Updated": a tool still inside its launch window is just new.
|
||||
export function getToolFreshness(
|
||||
tool: FreshnessFields,
|
||||
appVersion: string | null | undefined,
|
||||
): ToolFreshnessInfo | null {
|
||||
const { newInVersion, updatedInVersion } = tool;
|
||||
if (newInVersion && isRecentRelease(newInVersion, appVersion)) {
|
||||
return { badge: "new", version: latestTaggedVersion(tool) ?? newInVersion };
|
||||
}
|
||||
if (updatedInVersion && isRecentRelease(updatedInVersion, appVersion)) {
|
||||
return { badge: "updated", version: updatedInVersion };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type AcknowledgedVersions = Readonly<Record<string, string>>;
|
||||
|
||||
let acknowledgedCache: AcknowledgedVersions | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function readAcknowledged(): AcknowledgedVersions {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Stable snapshot for useSyncExternalStore; replaced wholesale on writes.
|
||||
export function getAcknowledgedToolVersions(): AcknowledgedVersions {
|
||||
if (!acknowledgedCache) acknowledgedCache = readAcknowledged();
|
||||
return acknowledgedCache;
|
||||
}
|
||||
|
||||
export function subscribeToolFreshness(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function isToolFreshnessAcknowledged(
|
||||
acknowledged: AcknowledgedVersions,
|
||||
toolId: string,
|
||||
version: string,
|
||||
): boolean {
|
||||
const seen = acknowledged[toolId];
|
||||
return (
|
||||
!!seen &&
|
||||
updateService.compareVersions(
|
||||
normalizeVersion(seen),
|
||||
normalizeVersion(version),
|
||||
) >= 0
|
||||
);
|
||||
}
|
||||
|
||||
// Records that the user has opened the tool at its currently tagged version.
|
||||
export function acknowledgeToolFreshness(
|
||||
toolId: string,
|
||||
tool: FreshnessFields,
|
||||
): void {
|
||||
const version = latestTaggedVersion(tool);
|
||||
if (!version) return;
|
||||
const current = getAcknowledgedToolVersions();
|
||||
if (isToolFreshnessAcknowledged(current, toolId, version)) return;
|
||||
acknowledgedCache = { ...current, [toolId]: version };
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(acknowledgedCache));
|
||||
} catch {
|
||||
// Storage being unavailable only means the badge reappears next visit.
|
||||
}
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
// Test hook: drops the in-memory cache so the next read hits localStorage.
|
||||
export function resetToolFreshnessCache(): void {
|
||||
acknowledgedCache = null;
|
||||
}
|
||||
@@ -92,7 +92,9 @@ function taskCommand() {
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return { command: candidate, shell: false };
|
||||
}
|
||||
return { command: process.platform === "win32" ? "task.cmd" : "task", shell: process.platform === "win32" };
|
||||
// Plain "task" lets cmd resolve the extension via PATHEXT, so .exe shims
|
||||
// (scoop, winget) work too; hard-coding .cmd made those exit 1 with no output.
|
||||
return { command: "task", shell: process.platform === "win32" };
|
||||
}
|
||||
|
||||
function run() {
|
||||
|
||||
Reference in New Issue
Block a user