mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
Merge origin/master into connection grant resolution
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
f5cbb2ac57
66 files changed
+3676
-1602
No files matched your search
@@ -84,6 +84,19 @@ export {
|
||||
type ResponsibleUserSource,
|
||||
type OriginatingActor,
|
||||
} from "./issue-attribution.js";
|
||||
export {
|
||||
ARTIFACT_REVIEW_DOCUMENT_KEY_PREFIX,
|
||||
MARKDOWN_ATTACHMENT_CONTENT_TYPES,
|
||||
MARKDOWN_REVIEW_DOCUMENT_MAX_BYTES,
|
||||
artifactReviewDocumentKey,
|
||||
getAttachmentArtifactWorkProductMetadata,
|
||||
getMarkdownWorkProductAttachmentMetadata,
|
||||
isArtifactReviewDocumentKey,
|
||||
isMarkdownArtifactWorkProduct,
|
||||
isMarkdownAttachmentContent,
|
||||
workProductIdFromArtifactReviewDocumentKey,
|
||||
type AttachmentArtifactWorkProductLike,
|
||||
} from "./markdown-work-products.js";
|
||||
export {
|
||||
ISSUE_WRITE_DENIAL_CODES,
|
||||
describeIssueWriteDenial,
|
||||
@@ -2510,6 +2523,23 @@ export {
|
||||
type FeatureTier,
|
||||
type InstanceFeatureKey,
|
||||
} from "./feature-catalog.js";
|
||||
export {
|
||||
HIDEABLE_GENERAL_SECTIONS,
|
||||
HIDEABLE_INSTANCE_PAGES,
|
||||
HIDEABLE_SETTING_KEYS,
|
||||
SETTINGS_OPERATOR_MANAGED_ERROR_CODE,
|
||||
UI_ONLY_GENERAL_SECTIONS,
|
||||
experimentalSettingKey,
|
||||
hidesExperimentalSetting,
|
||||
hidesGeneralSection,
|
||||
hidesInstancePage,
|
||||
parseHiddenSettingsList,
|
||||
type HideableExperimentalSetting,
|
||||
type HideableGeneralSection,
|
||||
type HideableInstancePage,
|
||||
type HideableSettingKey,
|
||||
type ParsedHiddenSettings,
|
||||
} from "./settings-visibility.js";
|
||||
|
||||
// --- Runtime exposure (opt-in Tailscale HTTPS for managed branch runtimes) ---
|
||||
// PAP-17049 plan, PAP-17050 threat-model verdict. Contract shared across DB,
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ARTIFACT_REVIEW_DOCUMENT_KEY_PREFIX,
|
||||
artifactReviewDocumentKey,
|
||||
getAttachmentArtifactWorkProductMetadata,
|
||||
getMarkdownWorkProductAttachmentMetadata,
|
||||
isArtifactReviewDocumentKey,
|
||||
isMarkdownArtifactWorkProduct,
|
||||
isMarkdownAttachmentContent,
|
||||
workProductIdFromArtifactReviewDocumentKey,
|
||||
} from "./markdown-work-products.js";
|
||||
|
||||
const ATTACHMENT_ID = "00000000-0000-4000-8000-000000000001";
|
||||
const WORK_PRODUCT_ID = "11111111-2222-4333-8444-555555555555";
|
||||
|
||||
function attachmentMetadata(overrides: Record<string, unknown> = {}) {
|
||||
const contentPath = `/api/attachments/${ATTACHMENT_ID}/content`;
|
||||
return {
|
||||
attachmentId: ATTACHMENT_ID,
|
||||
contentType: "text/markdown",
|
||||
byteSize: 128,
|
||||
contentPath,
|
||||
openPath: contentPath,
|
||||
downloadPath: `${contentPath}?download=1`,
|
||||
originalFilename: "notes.md",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function workProduct(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "artifact" as const,
|
||||
provider: "paperclip",
|
||||
metadata: attachmentMetadata(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isMarkdownAttachmentContent", () => {
|
||||
it.each([
|
||||
"text/markdown",
|
||||
"text/x-markdown",
|
||||
"application/markdown",
|
||||
"application/x-markdown",
|
||||
])("accepts %s regardless of filename", (contentType) => {
|
||||
expect(isMarkdownAttachmentContent({ contentType, originalFilename: null })).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores MIME parameters and case", () => {
|
||||
expect(
|
||||
isMarkdownAttachmentContent({
|
||||
contentType: "Text/Markdown; charset=utf-8",
|
||||
originalFilename: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"text/plain",
|
||||
"application/octet-stream",
|
||||
"binary/octet-stream",
|
||||
"application/x-binary",
|
||||
])("accepts a .md filename when the content type is %s", (contentType) => {
|
||||
expect(isMarkdownAttachmentContent({ contentType, originalFilename: "README.md" })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a .markdown filename with a generic content type", () => {
|
||||
expect(
|
||||
isMarkdownAttachmentContent({
|
||||
contentType: "application/octet-stream",
|
||||
originalFilename: "guide.markdown",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects .mdx files", () => {
|
||||
expect(
|
||||
isMarkdownAttachmentContent({
|
||||
contentType: "application/octet-stream",
|
||||
originalFilename: "page.mdx",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isMarkdownAttachmentContent({ contentType: "text/plain", originalFilename: "page.mdx" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects markdown filenames with non-generic content types", () => {
|
||||
expect(
|
||||
isMarkdownAttachmentContent({ contentType: "text/html", originalFilename: "notes.md" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-markdown filenames with generic content types", () => {
|
||||
expect(
|
||||
isMarkdownAttachmentContent({ contentType: "text/plain", originalFilename: "notes.txt" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a missing content type", () => {
|
||||
expect(isMarkdownAttachmentContent({ contentType: null, originalFilename: "notes.md" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAttachmentArtifactWorkProductMetadata", () => {
|
||||
it("returns canonical metadata for a valid paperclip artifact work product", () => {
|
||||
const metadata = getAttachmentArtifactWorkProductMetadata(workProduct());
|
||||
expect(metadata?.attachmentId).toBe(ATTACHMENT_ID);
|
||||
});
|
||||
|
||||
it("returns null for non-artifact work products", () => {
|
||||
expect(getAttachmentArtifactWorkProductMetadata(workProduct({ type: "document" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-paperclip providers", () => {
|
||||
expect(getAttachmentArtifactWorkProductMetadata(workProduct({ provider: "github" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when metadata is missing or non-canonical", () => {
|
||||
expect(getAttachmentArtifactWorkProductMetadata(workProduct({ metadata: null }))).toBeNull();
|
||||
expect(
|
||||
getAttachmentArtifactWorkProductMetadata(
|
||||
workProduct({
|
||||
metadata: attachmentMetadata({ openPath: "https://evil.example/content" }),
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMarkdownWorkProductAttachmentMetadata", () => {
|
||||
it("returns metadata only when the attachment is markdown-eligible", () => {
|
||||
expect(getMarkdownWorkProductAttachmentMetadata(workProduct())).not.toBeNull();
|
||||
expect(
|
||||
getMarkdownWorkProductAttachmentMetadata(
|
||||
workProduct({
|
||||
metadata: attachmentMetadata({ contentType: "application/pdf", originalFilename: "a.pdf" }),
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("backs the boolean eligibility helper", () => {
|
||||
expect(isMarkdownArtifactWorkProduct(workProduct())).toBe(true);
|
||||
expect(
|
||||
isMarkdownArtifactWorkProduct(
|
||||
workProduct({
|
||||
metadata: attachmentMetadata({ contentType: "image/png", originalFilename: "a.png" }),
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("artifact review document keys", () => {
|
||||
it("builds a deterministic key that fits the 64-character document key limit", () => {
|
||||
const key = artifactReviewDocumentKey(WORK_PRODUCT_ID);
|
||||
expect(key).toBe(`artifact-review-${WORK_PRODUCT_ID}`);
|
||||
expect(key.length).toBeLessThanOrEqual(64);
|
||||
expect(key).toMatch(/^[a-z0-9][a-z0-9_-]*$/);
|
||||
});
|
||||
|
||||
it("identifies proxy review-document keys", () => {
|
||||
expect(isArtifactReviewDocumentKey(artifactReviewDocumentKey(WORK_PRODUCT_ID))).toBe(true);
|
||||
expect(isArtifactReviewDocumentKey("plan")).toBe(false);
|
||||
expect(isArtifactReviewDocumentKey("notes")).toBe(false);
|
||||
expect(isArtifactReviewDocumentKey(ARTIFACT_REVIEW_DOCUMENT_KEY_PREFIX)).toBe(false);
|
||||
});
|
||||
|
||||
it("round-trips the work product id", () => {
|
||||
expect(
|
||||
workProductIdFromArtifactReviewDocumentKey(artifactReviewDocumentKey(WORK_PRODUCT_ID)),
|
||||
).toBe(WORK_PRODUCT_ID);
|
||||
expect(workProductIdFromArtifactReviewDocumentKey("plan")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import type {
|
||||
AttachmentArtifactWorkProductMetadata,
|
||||
IssueWorkProduct,
|
||||
} from "./types/work-product.js";
|
||||
import { attachmentArtifactWorkProductMetadataSchema } from "./validators/work-product.js";
|
||||
|
||||
/**
|
||||
* Shared classification for bridging attachment-backed Markdown work products
|
||||
* into the issue document review experience. Server materialization and UI
|
||||
* presentation must agree on eligibility, so both sides use these helpers.
|
||||
*/
|
||||
|
||||
export const MARKDOWN_ATTACHMENT_CONTENT_TYPES = [
|
||||
"text/markdown",
|
||||
"text/x-markdown",
|
||||
"application/markdown",
|
||||
"application/x-markdown",
|
||||
] as const;
|
||||
|
||||
const MARKDOWN_ATTACHMENT_CONTENT_TYPE_SET = new Set<string>(MARKDOWN_ATTACHMENT_CONTENT_TYPES);
|
||||
|
||||
const MARKDOWN_FALLBACK_CONTENT_TYPES = new Set([
|
||||
"text/plain",
|
||||
"application/octet-stream",
|
||||
"binary/octet-stream",
|
||||
"application/x-binary",
|
||||
]);
|
||||
|
||||
const MARKDOWN_FILENAME_EXTENSIONS = [".md", ".markdown"];
|
||||
|
||||
/**
|
||||
* Maximum attachment size eligible for review-document materialization.
|
||||
* Matches the issue-document body limit: a UTF-8 payload of at most this many
|
||||
* bytes always fits the 524288-character body cap.
|
||||
*/
|
||||
export const MARKDOWN_REVIEW_DOCUMENT_MAX_BYTES = 512 * 1024;
|
||||
|
||||
export const ARTIFACT_REVIEW_DOCUMENT_KEY_PREFIX = "artifact-review-";
|
||||
|
||||
function normalizeAttachmentContentType(contentType: string | null | undefined): string {
|
||||
return (contentType ?? "").toLowerCase().split(";")[0]?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function isMarkdownAttachmentContent(input: {
|
||||
contentType: string | null | undefined;
|
||||
originalFilename?: string | null;
|
||||
}): boolean {
|
||||
const contentType = normalizeAttachmentContentType(input.contentType);
|
||||
if (MARKDOWN_ATTACHMENT_CONTENT_TYPE_SET.has(contentType)) return true;
|
||||
const filename = (input.originalFilename ?? "").toLowerCase();
|
||||
if (!MARKDOWN_FILENAME_EXTENSIONS.some((extension) => filename.endsWith(extension))) {
|
||||
return false;
|
||||
}
|
||||
return MARKDOWN_FALLBACK_CONTENT_TYPES.has(contentType);
|
||||
}
|
||||
|
||||
export type AttachmentArtifactWorkProductLike = Pick<
|
||||
IssueWorkProduct,
|
||||
"type" | "provider" | "metadata"
|
||||
>;
|
||||
|
||||
export function getAttachmentArtifactWorkProductMetadata(
|
||||
workProduct: AttachmentArtifactWorkProductLike,
|
||||
): AttachmentArtifactWorkProductMetadata | null {
|
||||
if (workProduct.type !== "artifact" || workProduct.provider !== "paperclip") return null;
|
||||
if (!workProduct.metadata) return null;
|
||||
const parsed = attachmentArtifactWorkProductMetadataSchema.safeParse(workProduct.metadata);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export function getMarkdownWorkProductAttachmentMetadata(
|
||||
workProduct: AttachmentArtifactWorkProductLike,
|
||||
): AttachmentArtifactWorkProductMetadata | null {
|
||||
const metadata = getAttachmentArtifactWorkProductMetadata(workProduct);
|
||||
if (!metadata) return null;
|
||||
return isMarkdownAttachmentContent(metadata) ? metadata : null;
|
||||
}
|
||||
|
||||
export function isMarkdownArtifactWorkProduct(
|
||||
workProduct: AttachmentArtifactWorkProductLike,
|
||||
): boolean {
|
||||
return getMarkdownWorkProductAttachmentMetadata(workProduct) !== null;
|
||||
}
|
||||
|
||||
export function artifactReviewDocumentKey(workProductId: string): string {
|
||||
return `${ARTIFACT_REVIEW_DOCUMENT_KEY_PREFIX}${workProductId}`;
|
||||
}
|
||||
|
||||
export function isArtifactReviewDocumentKey(key: string): boolean {
|
||||
return (
|
||||
key.startsWith(ARTIFACT_REVIEW_DOCUMENT_KEY_PREFIX) &&
|
||||
key.length > ARTIFACT_REVIEW_DOCUMENT_KEY_PREFIX.length
|
||||
);
|
||||
}
|
||||
|
||||
export function workProductIdFromArtifactReviewDocumentKey(key: string): string | null {
|
||||
if (!isArtifactReviewDocumentKey(key)) return null;
|
||||
return key.slice(ARTIFACT_REVIEW_DOCUMENT_KEY_PREFIX.length);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { INSTANCE_FEATURE_KEYS } from "./feature-catalog.js";
|
||||
import {
|
||||
HIDEABLE_GENERAL_SECTIONS,
|
||||
HIDEABLE_SETTING_KEYS,
|
||||
UI_ONLY_GENERAL_SECTIONS,
|
||||
experimentalSettingKey,
|
||||
hidesExperimentalSetting,
|
||||
hidesGeneralSection,
|
||||
hidesInstancePage,
|
||||
parseHiddenSettingsList,
|
||||
} from "./settings-visibility.js";
|
||||
import { instanceGeneralSettingsSchema } from "./validators/instance.js";
|
||||
|
||||
describe("hideable setting keys", () => {
|
||||
it("derives one key per experimental flag and has no duplicates", () => {
|
||||
const experimental = HIDEABLE_SETTING_KEYS.filter(
|
||||
(key) => key.startsWith("instance.experimental."),
|
||||
);
|
||||
expect(experimental).toEqual(INSTANCE_FEATURE_KEYS.map(experimentalSettingKey));
|
||||
expect(new Set(HIDEABLE_SETTING_KEYS).size).toBe(HIDEABLE_SETTING_KEYS.length);
|
||||
});
|
||||
|
||||
it("maps field-backed general sections onto real general-settings fields", () => {
|
||||
const generalFields = Object.keys(instanceGeneralSettingsSchema.shape);
|
||||
const uiOnly = new Set<string>(UI_ONLY_GENERAL_SECTIONS);
|
||||
for (const section of HIDEABLE_GENERAL_SECTIONS) {
|
||||
if (uiOnly.has(section)) continue;
|
||||
expect(generalFields).toContain(section.slice("instance.general.".length));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHiddenSettingsList", () => {
|
||||
it("returns nothing hidden for undefined or empty input", () => {
|
||||
expect(parseHiddenSettingsList(undefined)).toEqual({ hidden: [], unknown: [] });
|
||||
expect(parseHiddenSettingsList(" , ,")).toEqual({ hidden: [], unknown: [] });
|
||||
});
|
||||
|
||||
it("splits known from unknown keys, trims, and deduplicates", () => {
|
||||
const parsed = parseHiddenSettingsList(
|
||||
"instance.plugins, instance.general.backupRetention ,instance.bogus,instance.plugins,instance.experimental.enableEnvironments",
|
||||
);
|
||||
expect(parsed.hidden).toEqual([
|
||||
"instance.plugins",
|
||||
"instance.general.backupRetention",
|
||||
"instance.experimental.enableEnvironments",
|
||||
]);
|
||||
expect(parsed.unknown).toEqual(["instance.bogus"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("membership helpers", () => {
|
||||
const hidden = new Set(
|
||||
parseHiddenSettingsList(
|
||||
"instance.plugins,instance.general.censorUsernameInLogs,instance.experimental.enableEnvironments",
|
||||
).hidden,
|
||||
);
|
||||
|
||||
it("answers page, section, and experimental membership", () => {
|
||||
expect(hidesInstancePage(hidden, "instance.plugins")).toBe(true);
|
||||
expect(hidesInstancePage(hidden, "instance.adapters")).toBe(false);
|
||||
expect(hidesGeneralSection(hidden, "instance.general.censorUsernameInLogs")).toBe(true);
|
||||
expect(hidesGeneralSection(hidden, "instance.general.backupRetention")).toBe(false);
|
||||
expect(hidesExperimentalSetting(hidden, "enableEnvironments")).toBe(true);
|
||||
expect(hidesExperimentalSetting(hidden, "enableIsolatedWorkspaces")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a hidden Experimental page as hiding every toggle", () => {
|
||||
const pageHidden = new Set(parseHiddenSettingsList("instance.experimental").hidden);
|
||||
expect(hidesExperimentalSetting(pageHidden, "enableEnvironments")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { INSTANCE_FEATURE_KEYS, type InstanceFeatureKey } from "./feature-catalog.js";
|
||||
|
||||
/**
|
||||
* Operator-configurable settings visibility.
|
||||
*
|
||||
* A hosting operator (a managed cloud, an internal shared server) can hide
|
||||
* instance-settings surfaces that do not apply to their deployment by setting
|
||||
* the `PAPERCLIP_HIDDEN_SETTINGS` environment variable to a comma-separated
|
||||
* list of keys from this registry. Hiding a surface removes it from the UI
|
||||
* (nav, routes, page sections). Surfaces backed by instance-level mutation
|
||||
* routes are also floored with a 403 carrying
|
||||
* `SETTINGS_OPERATOR_MANAGED_ERROR_CODE`: the Access, Plugins, and Adapters
|
||||
* pages, every field-backed General section, and every experimental toggle
|
||||
* (individually or via the whole Experimental page).
|
||||
*
|
||||
* Nothing is hidden by default: with the variable unset, UI and API behave
|
||||
* exactly as before this mechanism existed.
|
||||
*
|
||||
* Unknown keys are ignored (with a server-side warning) rather than rejected,
|
||||
* so an operator may roll one list across a fleet of mixed app versions: an
|
||||
* image that predates a key simply keeps that surface visible instead of
|
||||
* refusing to boot.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Instance settings pages that can be hidden (nav entry + route). The General
|
||||
* page is deliberately not hideable: it is the settings root and the redirect
|
||||
* target for hidden pages. Individual General sections are hideable below.
|
||||
*/
|
||||
export const HIDEABLE_INSTANCE_PAGES = [
|
||||
"instance.profile",
|
||||
"instance.environments",
|
||||
"instance.access",
|
||||
"instance.heartbeats",
|
||||
"instance.experimental",
|
||||
"instance.plugins",
|
||||
"instance.adapters",
|
||||
] as const;
|
||||
|
||||
export type HideableInstancePage = (typeof HIDEABLE_INSTANCE_PAGES)[number];
|
||||
|
||||
/**
|
||||
* Sections of Instance → General that can be hidden. Field-backed sections
|
||||
* (their suffix names a general-settings field) also floor writes to that
|
||||
* field; `deploymentStatus` and `signOut` are read-only UI with no field.
|
||||
*/
|
||||
export const HIDEABLE_GENERAL_SECTIONS = [
|
||||
"instance.general.deploymentStatus",
|
||||
"instance.general.censorUsernameInLogs",
|
||||
"instance.general.keyboardShortcuts",
|
||||
"instance.general.backupRetention",
|
||||
"instance.general.feedbackDataSharingPreference",
|
||||
"instance.general.signOut",
|
||||
] as const;
|
||||
|
||||
export type HideableGeneralSection = (typeof HIDEABLE_GENERAL_SECTIONS)[number];
|
||||
|
||||
/** General sections that are informational UI only, with no settings field. */
|
||||
export const UI_ONLY_GENERAL_SECTIONS = [
|
||||
"instance.general.deploymentStatus",
|
||||
"instance.general.signOut",
|
||||
] as const satisfies readonly HideableGeneralSection[];
|
||||
|
||||
export type HideableExperimentalSetting = `instance.experimental.${InstanceFeatureKey}`;
|
||||
|
||||
/** The visibility key for an experimental toggle; every boolean flag is hideable. */
|
||||
export function experimentalSettingKey(key: InstanceFeatureKey): HideableExperimentalSetting {
|
||||
return `instance.experimental.${key}`;
|
||||
}
|
||||
|
||||
export type HideableSettingKey =
|
||||
| HideableInstancePage
|
||||
| HideableGeneralSection
|
||||
| HideableExperimentalSetting;
|
||||
|
||||
/** Every key `PAPERCLIP_HIDDEN_SETTINGS` accepts. */
|
||||
export const HIDEABLE_SETTING_KEYS: readonly HideableSettingKey[] = [
|
||||
...HIDEABLE_INSTANCE_PAGES,
|
||||
...HIDEABLE_GENERAL_SECTIONS,
|
||||
...INSTANCE_FEATURE_KEYS.map(experimentalSettingKey),
|
||||
];
|
||||
|
||||
/** Stable 403 code for writes to operator-hidden settings. */
|
||||
export const SETTINGS_OPERATOR_MANAGED_ERROR_CODE = "settings_operator_managed";
|
||||
|
||||
export interface ParsedHiddenSettings {
|
||||
/** Recognized keys, deduplicated, in input order. */
|
||||
hidden: HideableSettingKey[];
|
||||
/** Unrecognized entries, for the caller to warn about. */
|
||||
unknown: string[];
|
||||
}
|
||||
|
||||
/** Parse a `PAPERCLIP_HIDDEN_SETTINGS`-style comma-separated list. */
|
||||
export function parseHiddenSettingsList(raw: string | undefined): ParsedHiddenSettings {
|
||||
const hidden: HideableSettingKey[] = [];
|
||||
const unknown: string[] = [];
|
||||
if (!raw) return { hidden, unknown };
|
||||
const known = new Set<string>(HIDEABLE_SETTING_KEYS);
|
||||
const seen = new Set<string>();
|
||||
for (const part of raw.split(",")) {
|
||||
const key = part.trim();
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
if (known.has(key)) {
|
||||
hidden.push(key as HideableSettingKey);
|
||||
} else {
|
||||
unknown.push(key);
|
||||
}
|
||||
}
|
||||
return { hidden, unknown };
|
||||
}
|
||||
|
||||
export function hidesInstancePage(
|
||||
hidden: ReadonlySet<string>,
|
||||
page: HideableInstancePage,
|
||||
): boolean {
|
||||
return hidden.has(page);
|
||||
}
|
||||
|
||||
export function hidesGeneralSection(
|
||||
hidden: ReadonlySet<string>,
|
||||
section: HideableGeneralSection,
|
||||
): boolean {
|
||||
return hidden.has(section);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a toggle is hidden, either individually or because the whole
|
||||
* Experimental page is hidden.
|
||||
*/
|
||||
export function hidesExperimentalSetting(
|
||||
hidden: ReadonlySet<string>,
|
||||
key: InstanceFeatureKey,
|
||||
): boolean {
|
||||
return hidden.has("instance.experimental") || hidden.has(experimentalSettingKey(key));
|
||||
}
|
||||
Reference in new issue
Block a user