Some checks failed
CD / Build and push images (push) Successful in 4m59s
CD / Deploy to Test (push) Successful in 11s
CI / Lint, typecheck, test (push) Failing after 5m36s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m21s
CD / Promote to Int (push) Successful in 11s
Real vault ZIPs broke umlauts in page titles ("Fußball zum Götzen" →
mojibake, slug fua-ball…goi-tzen): fflate honors only the ZIP UTF-8
flag, which common archivers omit, and decodes unflagged names as
Latin-1. That decoding is byte-lossless, so parseVaultZip now re-reads
any name whose chars all fit one byte as UTF-8 (a strict decoder —
genuine Latin-1 and flag-decoded precomposed chars fall back
unchanged), then NFC-normalizes: macOS zips store umlauts decomposed,
which silently broke slugify's ä→ae digraphs, wikilink matching, and
duplicate-basename detection. slugify itself also precomposes first as
defense in depth for NFD input from other paths.
Unit tests pin both cases: a hand-patched ZIP whose UTF-8 name bytes
carry no UTF-8 flag, and an NFD-named note that must come out
precomposed with an ueber- slug.
Pages already imported with garbled titles stay as they are — delete
the imported subtree and re-import after this lands (or rename by
hand).
Fixes #127
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn
133 lines
4.8 KiB
TypeScript
133 lines
4.8 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
import { COMMENT_POLICIES } from './comments';
|
|
|
|
/**
|
|
* Pond schemas and views shared between api and web (issue #21).
|
|
* Ponds are the top-level content container; every self-registered
|
|
* person owns exactly one personal pond (data-model.md §ponds).
|
|
*/
|
|
|
|
export const POND_TYPES = ['personal', 'shared'] as const;
|
|
export type PondType = (typeof POND_TYPES)[number];
|
|
|
|
export const SIDEBAR_SORT_MODES = ['alpha', 'created', 'manual'] as const;
|
|
export type SidebarSortMode = (typeof SIDEBAR_SORT_MODES)[number];
|
|
|
|
/** How the sidebar presents a pond's pages (issue #108): as the page tree
|
|
* (`folders`) or grouped under the label tree (`labels`). */
|
|
export const SIDEBAR_VIEW_MODES = ['folders', 'labels'] as const;
|
|
export type SidebarViewMode = (typeof SIDEBAR_VIEW_MODES)[number];
|
|
|
|
/** One font slot per ADR 0016; values reference the curated catalog. */
|
|
const fontSlotSchema = z.object({
|
|
family: z.string().min(1).max(80),
|
|
weight: z.number().int().min(100).max(900),
|
|
});
|
|
|
|
/** The three font slots, each defaulted to the vision fonts (ADR 0016). */
|
|
export const pondFontsSchema = z.object({
|
|
heading: fontSlotSchema.default({ family: 'Roboto', weight: 400 }),
|
|
body: fontSlotSchema.default({ family: 'Roboto', weight: 200 }),
|
|
mono: fontSlotSchema.default({ family: 'Fira Code', weight: 400 }),
|
|
});
|
|
export type PondFonts = z.infer<typeof pondFontsSchema>;
|
|
|
|
/**
|
|
* Pond `settings` jsonb. Parsing `{}` yields the documented defaults
|
|
* (sidebar sort `alpha`; fonts Roboto 400 / Roboto 200 / Fira Code,
|
|
* ADR 0016) — persisted settings therefore only need to store what the
|
|
* pond actually changed.
|
|
*/
|
|
export const pondSettingsSchema = z.object({
|
|
sidebarSort: z.enum(SIDEBAR_SORT_MODES).default('alpha'),
|
|
/** The pond default for the sidebar's page presentation (issue #108);
|
|
* every member can override it locally (`ui.sidebar.view.<pondId>`). */
|
|
sidebarView: z.enum(SIDEBAR_VIEW_MODES).default('folders'),
|
|
fonts: pondFontsSchema.default({}),
|
|
/** Who may write comments (issue #91): every reader, or editors only. */
|
|
commentPolicy: z.enum(COMMENT_POLICIES).default('readers'),
|
|
/** Per-pond opt-in to the public REST API (issue #104, default off):
|
|
* without it the pond and its content answer 404 through the API even
|
|
* for a token whose user could see them in the app. */
|
|
apiEnabled: z.boolean().default(false),
|
|
/** Per-pond opt-in to the built-in MCP endpoint (issue #105, default
|
|
* off) — independent of the REST opt-in. */
|
|
mcpEnabled: z.boolean().default(false),
|
|
});
|
|
export type PondSettings = z.infer<typeof pondSettingsSchema>;
|
|
|
|
/** The machine-access surfaces a pond can opt into (issues #104/#105). */
|
|
export type PondExposureFeature = 'api' | 'mcp';
|
|
|
|
export function pondFeatureEnabled(settings: PondSettings, feature: PondExposureFeature): boolean {
|
|
return feature === 'api' ? settings.apiEnabled : settings.mcpEnabled;
|
|
}
|
|
|
|
export const pondNameSchema = z
|
|
.string()
|
|
.trim()
|
|
.min(1, 'validation.required')
|
|
.max(80, 'validation.tooLong');
|
|
|
|
export const createPondInputSchema = z.object({
|
|
name: pondNameSchema,
|
|
description: z.string().trim().max(500, 'validation.tooLong').default(''),
|
|
});
|
|
export type CreatePondInput = z.infer<typeof createPondInputSchema>;
|
|
|
|
export const updatePondInputSchema = z
|
|
.object({
|
|
name: pondNameSchema,
|
|
description: z.string().trim().max(500, 'validation.tooLong'),
|
|
sidebarSort: z.enum(SIDEBAR_SORT_MODES),
|
|
sidebarView: z.enum(SIDEBAR_VIEW_MODES),
|
|
fonts: pondFontsSchema,
|
|
commentPolicy: z.enum(COMMENT_POLICIES),
|
|
apiEnabled: z.boolean(),
|
|
mcpEnabled: z.boolean(),
|
|
})
|
|
.partial();
|
|
export type UpdatePondInput = z.infer<typeof updatePondInputSchema>;
|
|
|
|
/** What the api returns for a pond; settings come fully defaulted. */
|
|
export interface PondView {
|
|
id: string;
|
|
slug: string;
|
|
name: string;
|
|
description: string;
|
|
type: PondType;
|
|
ownerId: string;
|
|
settings: PondSettings;
|
|
createdAt: string;
|
|
deletedAt: string | null;
|
|
}
|
|
|
|
const MAX_SLUG_LENGTH = 60;
|
|
|
|
/**
|
|
* Derives a URL-safe slug: German transliteration (ä→ae …), diacritics
|
|
* stripped, everything else collapsed to single hyphens. Returns '' when
|
|
* nothing survives — callers pick their fallback (e.g. the username).
|
|
* Uniqueness (numeric suffixes) is the caller's job, not slugify's.
|
|
*/
|
|
export function slugify(input: string): string {
|
|
return (
|
|
input
|
|
// Precompose first (#127): macOS file names arrive NFD-decomposed, and
|
|
// the umlaut digraphs below only match the precomposed forms.
|
|
.normalize('NFC')
|
|
.toLowerCase()
|
|
.replace(/ä/g, 'ae')
|
|
.replace(/ö/g, 'oe')
|
|
.replace(/ü/g, 'ue')
|
|
.replace(/ß/g, 'ss')
|
|
.normalize('NFKD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, MAX_SLUG_LENGTH)
|
|
.replace(/-+$/, '')
|
|
);
|
|
}
|