diff --git a/frontend/src/common/instances.js b/frontend/src/common/instances.js index 5905c3fda..d2b17f7f2 100644 --- a/frontend/src/common/instances.js +++ b/frontend/src/common/instances.js @@ -39,6 +39,31 @@ export const InstanceIdentityKeys = [InstanceUrlKey, InstanceTitleKey]; // safeWindow returns the browser window if available, else null. const safeWindow = () => (typeof window === "undefined" ? null : window); +// basePathSegment returns the last non-empty path segment of a URL (e.g. +// "https://app.example.com/i/pro-1/" -> "pro-1"), or "" when the URL is invalid +// or root-pathed. +function basePathSegment(url) { + if (typeof url !== "string" || url === "") { + return ""; + } + let pathname; + try { + pathname = new URL(url, safeWindow()?.location?.origin || "http://localhost/").pathname; + } catch { + return ""; + } + const segments = pathname.split("/").filter((s) => s !== ""); + return segments.length ? segments[segments.length - 1] : ""; +} + +// instanceLabel derives a distinctive switcher label for an instance: its base +// path segment when present (same-origin instances differ only by path and +// commonly share a generic title like "PhotoPrism"), else the recorded title, +// else the URL. +export function instanceLabel(url, title) { + return basePathSegment(url) || title || url || ""; +} + // persistInstanceIdentity records this instance's SiteUrl and display title in // the given (namespaced) store, so other instances on the same origin can list // it in the navigation instance switcher. No-op without a URL or usable store. @@ -105,7 +130,7 @@ export function listReachableInstances(options) { instances.push({ namespace, url, - title: store.getItem(prefix + InstanceTitleKey) || url, + title: instanceLabel(url, store.getItem(prefix + InstanceTitleKey)), }); }); }); diff --git a/frontend/tests/vitest/common/instances.test.js b/frontend/tests/vitest/common/instances.test.js index 4cb055982..d6423ac8a 100644 --- a/frontend/tests/vitest/common/instances.test.js +++ b/frontend/tests/vitest/common/instances.test.js @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import StorageShim from "node-storage-shim"; import { buildNamespace, createNamespacedStorage } from "common/storage"; -import { persistInstanceIdentity, listReachableInstances, InstanceIdentityKeys } from "common/instances"; +import { persistInstanceIdentity, listReachableInstances, instanceLabel, InstanceIdentityKeys } from "common/instances"; // seedInstance writes a peer instance's session token and identity into a shared store. const seedInstance = (store, namespace, { token = "tok", url, title } = {}) => { @@ -50,6 +50,23 @@ describe("common/instances", () => { }); }); + describe("instanceLabel", () => { + it("prefers the last base-path segment", () => { + expect(instanceLabel("https://app.example.com/i/pro-1/", "PhotoPrism")).toBe("pro-1"); + expect(instanceLabel("https://app.example.com/instance/foo", "PhotoPrism")).toBe("foo"); + }); + it("falls back to the title for root-pathed URLs", () => { + expect(instanceLabel("https://pro-1.example.com/", "Pro One")).toBe("Pro One"); + }); + it("falls back to the url when no title and no path segment", () => { + expect(instanceLabel("https://pro-1.example.com/", "")).toBe("https://pro-1.example.com/"); + }); + it("returns empty for invalid input", () => { + expect(instanceLabel("", "")).toBe(""); + expect(instanceLabel(null, null)).toBe(""); + }); + }); + describe("listReachableInstances", () => { it("returns peer instances excluding the current namespace", () => { const store = new StorageShim(); @@ -66,6 +83,14 @@ describe("common/instances", () => { const result = listReachableInstances({ currentNamespace: "ns-pro-1", storage: store }); expect(result[0].title).toBe("https://pro-2.example.com/"); }); + it("labels same-origin path-based instances by their distinctive base-path segment", () => { + // Both share the generic title "PhotoPrism"; the path segment disambiguates. + const store = new StorageShim(); + seedInstance(store, "ns-pro-1", { url: "https://app.example.com/i/pro-1/", title: "PhotoPrism" }); + seedInstance(store, "ns-pro-2", { url: "https://app.example.com/i/pro-2/", title: "PhotoPrism" }); + const result = listReachableInstances({ currentNamespace: "ns-pro-1", storage: store }); + expect(result[0]).toMatchObject({ url: "https://app.example.com/i/pro-2/", title: "pro-2" }); + }); it("ignores namespaces that have a session but no recorded identity", () => { const store = new StorageShim(); seedInstance(store, "ns-pro-1", { url: "https://pro-1.example.com/" });