includio-cms 0.36.8 → 0.36.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/API.md CHANGED
@@ -1,8 +1,8 @@
1
- # includio-cms — Public API v0.36.8
1
+ # includio-cms — Public API v0.36.9
2
2
 
3
3
  > Auto-generated by `scripts/generate-api-md.ts`. Do not edit by hand.
4
4
 
5
- Entry points: **19** · Stable: **501** · Experimental: **4**
5
+ Entry points: **19** · Stable: **502** · Experimental: **4**
6
6
 
7
7
  Tags:
8
8
  - `@public` — frozen for v1.0; semver-protected.
@@ -224,6 +224,7 @@ Tags:
224
224
  - `const deleteOrderCmd: <inferred>`
225
225
  - `const deleteShippingMethodCmd: <inferred>`
226
226
  - `const deleteShopDataForEntry: <inferred>`
227
+ - `const duplicateEntryCommand: <inferred>`
227
228
  - `const exportFormSubmissions: <inferred>`
228
229
  - `const exportOrdersCsv: <inferred>`
229
230
  - `const findMediaReferences: <inferred>`
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to includio-cms are documented here.
4
4
  Generated from `src/lib/updates/` — do not edit manually.
5
5
 
6
+ ## 0.36.9 — 2026-07-02
7
+
8
+ Duplikacja wpisu z tabeli kolekcji + serwerowa normalizacja pól slug przy zapisie. Menu wiersza zyskuje „Duplikuj" — tworzy nieopublikowany szkic z tą samą treścią (wszystkie języki), z prefiksem tytułu „Kopia — " i unikalnym slugiem („-kopia"), i przenosi do edytora duplikatu. Media i relacje są współdzielone przez referencje (bez głębokiego klonu). Niezależnie od duplikacji: każde pole typu slug (SlugField oraz slug w polu SEO) jest przy zapisie normalizowane przez slugify — „Singapur"/„Hello World" zapisują się jako „singapur"/„hello-world", transparentnie dla użytkownika. Spec: `docs/superpowers/specs/2026-07-02-duplicate-entry-and-slug-normalization-design.md`, plan: `docs/superpowers/plans/2026-07-02-duplicate-entry-and-slug-normalization.md`.
9
+
10
+ ### Added
11
+ - Duplikacja wpisu (`duplicateEntry` + remote `duplicateEntryCommand`, akcja „Duplikuj" w menu wiersza tabeli kolekcji `/admin/collections/[collection]`): kopiuje najświeższą treść per język (draft ?? published ?? scheduled) na nowy wpis jako wersje nieopublikowane (`publishedAt: null`), prefiksuje `entryAdminTitle` przez „Kopia — " i nadaje każdemu polu slug unikalny sufiks („-kopia", „-kopia-2", …) sprawdzany wobec published-or-scheduled slugów kolekcji, po czym przenosi do edytora duplikatu. Media/relacje = współdzielone referencje. Akcja ukryta dla wpisów archiwalnych.
12
+
13
+ ### Fixed
14
+ - Normalizacja slug przy zapisie (`normalizeSlugFields` wpięte w `updateEntryVersionCommand`, ścieżka draft i publish): wartości pól typu slug (SlugField oraz slug w polu SEO) są normalizowane przez `slugify({ lower, strict, trim })` przed zapisem i przed kontrolą unikalności. Nietechniczny użytkownik wpisujący np. „Singapur" czy „Hello World" dostaje poprawny „singapur"/„hello-world" bez ręcznej korekty. Puste/nie-string i pola nie-slug pozostają nietknięte.
15
+
6
16
  ## 0.36.8 — 2026-07-02
7
17
 
8
18
  Optymalizacja obrazów przeniesiona do tła: render nigdy nie odpala sharpa. Warianty generuje in-process worker zasilany trwałą kolejką media_job (concurrency=1, twarde limity sharp, retry/backoff, circuit-breaker). Lżejsza, konfigurowalna matryca (avif+webp, bez pełno-res bazy, cap 2560) tnie ~połowę plików/jobów i eliminuje bombę CPU pełno-res AVIF, która OOM-owała VPS.
package/DOCS.md CHANGED
@@ -1,4 +1,4 @@
1
- # Includio CMS Documentation (v0.36.8)
1
+ # Includio CMS Documentation (v0.36.9)
2
2
 
3
3
  > This file is auto-generated from the docs site. For the latest version, update the package.
4
4
 
@@ -4,6 +4,7 @@
4
4
  import { getEntryThumbnail, type MediaThumbnailLookup } from '../../utils/entryThumbnail.js';
5
5
  import { arrayMove } from '../../utils/arrayMove.js';
6
6
  import { getRemotes } from '../../../sveltekit/index.js';
7
+ import { goto } from '$app/navigation';
7
8
  import type { CollectionConfigWithType } from '../../../types/collections.js';
8
9
  import DataTable from './data-table.svelte';
9
10
  import type { ColumnDef, RowSelectionState, Table } from '@tanstack/table-core';
@@ -54,6 +55,8 @@
54
55
  entryRestored: string;
55
56
  entriesRestored: string;
56
57
  entryDeleted: string;
58
+ entryDuplicated: string;
59
+ entryDuplicateFailed: string;
57
60
  deleteConfirmTitle: string;
58
61
  deleteConfirmDescription: string;
59
62
  cancel: string;
@@ -74,6 +77,8 @@
74
77
  entryRestored: 'Entry restored',
75
78
  entriesRestored: 'Entries restored',
76
79
  entryDeleted: 'Entry permanently deleted',
80
+ entryDuplicated: 'Duplicate created (draft)',
81
+ entryDuplicateFailed: 'Failed to duplicate entry',
77
82
  deleteConfirmTitle: 'Delete entry permanently?',
78
83
  deleteConfirmDescription:
79
84
  'This action cannot be undone. The entry will be permanently deleted.',
@@ -94,6 +99,8 @@
94
99
  entryRestored: 'Wpis przywrócony',
95
100
  entriesRestored: 'Wpisy przywrócone',
96
101
  entryDeleted: 'Wpis trwale usunięty',
102
+ entryDuplicated: 'Utworzono duplikat (szkic)',
103
+ entryDuplicateFailed: 'Nie udało się zduplikować wpisu',
97
104
  deleteConfirmTitle: 'Usunąć wpis na stałe?',
98
105
  deleteConfirmDescription: 'Ta akcja jest nieodwracalna. Wpis zostanie trwale usunięty.',
99
106
  cancel: 'Anuluj',
@@ -374,7 +381,9 @@
374
381
  entryUrl: info.row.original.url,
375
382
  entryName: info.row.original.name,
376
383
  onArchive: () => handleArchiveSingle(info.row.original.id),
377
- ...(isArchivedFilter ? { onDelete: () => handleDelete(info.row.original.id), onRestore: () => handleRestoreSingle(info.row.original.id) } : {})
384
+ ...(isArchivedFilter
385
+ ? { onDelete: () => handleDelete(info.row.original.id), onRestore: () => handleRestoreSingle(info.row.original.id) }
386
+ : { onDuplicate: () => handleDuplicate(info.row.original.id) })
378
387
  }),
379
388
  enableSorting: false,
380
389
  size: 50
@@ -393,6 +402,16 @@
393
402
  override.release();
394
403
  }
395
404
 
405
+ async function handleDuplicate(id: string) {
406
+ try {
407
+ const { id: newId } = await remotes.duplicateEntryCommand(id);
408
+ toast.success(lang[interfaceLanguage.current].entryDuplicated);
409
+ await goto(`/admin/entries/${newId}`);
410
+ } catch {
411
+ toast.error(lang[interfaceLanguage.current].entryDuplicateFailed);
412
+ }
413
+ }
414
+
396
415
  async function handleRestoreSingle(id: string) {
397
416
  const override = entriesQuery.withOverride((old) => ({
398
417
  ...old,
@@ -4,6 +4,7 @@
4
4
  import Archive from '@tabler/icons-svelte/icons/archive';
5
5
  import ArchiveOff from '@tabler/icons-svelte/icons/archive-off';
6
6
  import Trash from '@tabler/icons-svelte/icons/trash';
7
+ import Copy from '@tabler/icons-svelte/icons/copy';
7
8
  import Button from '../../../components/ui/button/button.svelte';
8
9
  import * as DropdownMenu from '../../../components/ui/dropdown-menu/index.js';
9
10
  import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
@@ -14,17 +15,18 @@
14
15
  entryUrl: string;
15
16
  entryName: string;
16
17
  onArchive: () => void;
18
+ onDuplicate?: () => void;
17
19
  onDelete?: () => void;
18
20
  onRestore?: () => void;
19
21
  };
20
22
 
21
- let { entryUrl, entryName, onArchive, onDelete, onRestore }: Props = $props();
23
+ let { entryUrl, entryName, onArchive, onDuplicate, onDelete, onRestore }: Props = $props();
22
24
 
23
25
  const interfaceLanguage = useInterfaceLanguage();
24
26
 
25
- const lang: Record<InterfaceLanguage, { open: string; archive: string; restore: string; delete: string; moreActions: string }> = {
26
- en: { open: 'Open', archive: 'Archive', restore: 'Restore', delete: 'Delete', moreActions: 'More actions for' },
27
- pl: { open: 'Otwórz', archive: 'Archiwizuj', restore: 'Przywróć', delete: 'Usuń', moreActions: 'Więcej akcji dla' }
27
+ const lang: Record<InterfaceLanguage, { open: string; duplicate: string; archive: string; restore: string; delete: string; moreActions: string }> = {
28
+ en: { open: 'Open', duplicate: 'Duplicate', archive: 'Archive', restore: 'Restore', delete: 'Delete', moreActions: 'More actions for' },
29
+ pl: { open: 'Otwórz', duplicate: 'Duplikuj', archive: 'Archiwizuj', restore: 'Przywróć', delete: 'Usuń', moreActions: 'Więcej akcji dla' }
28
30
  };
29
31
 
30
32
  const t = $derived(lang[interfaceLanguage.current]);
@@ -51,6 +53,12 @@
51
53
  {t.open}
52
54
  </DropdownMenu.Item>
53
55
  <DropdownMenu.Separator />
56
+ {#if onDuplicate}
57
+ <DropdownMenu.Item onclick={onDuplicate} data-testid="row-action-duplicate">
58
+ <Copy class="mr-2 size-4" />
59
+ {t.duplicate}
60
+ </DropdownMenu.Item>
61
+ {/if}
54
62
  {#if onRestore}
55
63
  <DropdownMenu.Item onclick={onRestore} data-testid="row-action-restore">
56
64
  <ArchiveOff class="mr-2 size-4" />
@@ -2,6 +2,7 @@ type Props = {
2
2
  entryUrl: string;
3
3
  entryName: string;
4
4
  onArchive: () => void;
5
+ onDuplicate?: () => void;
5
6
  onDelete?: () => void;
6
7
  onRestore?: () => void;
7
8
  };
@@ -90,6 +90,9 @@ export declare const updateEntryCommand: import("@sveltejs/kit").RemoteCommand<{
90
90
  export declare const archiveEntryCommand: import("@sveltejs/kit").RemoteCommand<string, Promise<import("../../types/entries.js").DbEntry>>;
91
91
  export declare const unarchiveEntryCommand: import("@sveltejs/kit").RemoteCommand<string, Promise<import("../../types/entries.js").DbEntry>>;
92
92
  export declare const deleteEntryCommand: import("@sveltejs/kit").RemoteCommand<string, Promise<void>>;
93
+ export declare const duplicateEntryCommand: import("@sveltejs/kit").RemoteCommand<string, Promise<{
94
+ id: string;
95
+ }>>;
93
96
  export declare const reorderEntriesCommand: import("@sveltejs/kit").RemoteCommand<{
94
97
  orderedIds: string[];
95
98
  collectionSlug: string;
@@ -6,11 +6,13 @@ import { db } from '../../server/db/index.js';
6
6
  import { user } from '../../server/db/schema/auth-schema.js';
7
7
  import { inArray } from 'drizzle-orm';
8
8
  import { createEntry as createEntryOperation, createEntrySchema, createEntryVersion } from '../../core/server/entries/operations/create.js';
9
- import { _getRawEntries as getRawEntriesOperation, _countRawEntries as countRawEntriesOperation, _getRawEntry as getRawEntryOperation, _getRawEntryOrThrow as getRawEntryOrThrow, _getDbEntry as getDbEntry, getEntryVersion as getEntryVersionOperation, getEntryLabels as getEntryLabelsOperation } from '../../core/server/entries/operations/get.js';
9
+ import { _getRawEntries as getRawEntriesOperation, _countRawEntries as countRawEntriesOperation, _getRawEntry as getRawEntryOperation, _getRawEntryOrThrow as getRawEntryOrThrow, _getDbEntry as getDbEntry, _getDbEntryOrThrow as getDbEntryOrThrow, getEntryVersion as getEntryVersionOperation, getEntryLabels as getEntryLabelsOperation } from '../../core/server/entries/operations/get.js';
10
10
  import { resolveEntries, resolveEntry } from '../../core/server/entries/operations/resolveEntry.js';
11
11
  import { getCMS } from '../../core/cms.js';
12
12
  import { getFieldsFromConfig } from '../../core/fields/layoutUtils.js';
13
13
  import { pruneOldDraftVersions, unpublishEntryLang, cancelScheduledEntryLang, upsertDraftVersion, updateEntry, updateEntrySchema, updateEntryVersionCommandTypes } from '../../core/server/entries/operations/update.js';
14
+ import { normalizeSlugFields } from '../../core/server/entries/operations/slugNormalize.js';
15
+ import { duplicateEntry } from '../../core/server/entries/operations/duplicate.js';
14
16
  import z from 'zod';
15
17
  import { requireAuth } from './middleware/auth.js';
16
18
  import { stableSlotReorder } from './reorder.js';
@@ -205,6 +207,11 @@ const updateEntryVersionCommandSchema = z.object({
205
207
  });
206
208
  export const updateEntryVersionCommand = command(updateEntryVersionCommandSchema, async (input) => {
207
209
  const { user } = requireAuth();
210
+ // Normalize slug fields once, before any write path (covers draft in-place
211
+ // updates and publish). Keeps stored slugs valid regardless of raw input.
212
+ const entryForSlug = await getDbEntryOrThrow({ id: input.entryId });
213
+ const config = getCMS().getBySlug(entryForSlug.slug);
214
+ const data = normalizeSlugFields(config, input.data);
208
215
  let result;
209
216
  // Publish failures should reach the client with a structured body so the
210
217
  // editor can pin the message to the offending field. SvelteKit hides any
@@ -214,7 +221,7 @@ export const updateEntryVersionCommand = command(updateEntryVersionCommandSchema
214
221
  return await createEntryVersion({
215
222
  entryId: input.entryId,
216
223
  lang: input.lang,
217
- data: input.data,
224
+ data,
218
225
  publishedAt,
219
226
  publishedBy: user.id
220
227
  });
@@ -232,7 +239,7 @@ export const updateEntryVersionCommand = command(updateEntryVersionCommandSchema
232
239
  };
233
240
  switch (input.type) {
234
241
  case 'draft':
235
- result = await upsertDraftVersion(input.entryId, input.data, input.lang, { skipValidation: true });
242
+ result = await upsertDraftVersion(input.entryId, data, input.lang, { skipValidation: true });
236
243
  break;
237
244
  case 'published-now': {
238
245
  result = await runPublish(new Date());
@@ -289,6 +296,10 @@ export const deleteEntryCommand = command(z.string().uuid(), async (id) => {
289
296
  }
290
297
  return getCMS().databaseAdapter.deleteEntry({ id });
291
298
  });
299
+ export const duplicateEntryCommand = command(z.string().uuid(), async (id) => {
300
+ requireAuth();
301
+ return duplicateEntry(id);
302
+ });
292
303
  export const reorderEntriesCommand = command(z.object({
293
304
  orderedIds: z.array(z.string().uuid()),
294
305
  collectionSlug: z.string()
@@ -0,0 +1,4 @@
1
+ /** Read a value at a dot-path. Returns undefined if any segment is missing. */
2
+ export declare function getAtPath(obj: Record<string, any>, path: string): any;
3
+ /** Set a value at a dot-path, creating intermediate objects. Mutates `obj` in place. */
4
+ export declare function setAtPath(obj: Record<string, unknown>, path: string, value: unknown): void;
@@ -0,0 +1,18 @@
1
+ /** Read a value at a dot-path. Returns undefined if any segment is missing. */
2
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3
+ export function getAtPath(obj, path) {
4
+ const parts = path.split('.');
5
+ return parts.reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : undefined), obj);
6
+ }
7
+ /** Set a value at a dot-path, creating intermediate objects. Mutates `obj` in place. */
8
+ export function setAtPath(obj, path, value) {
9
+ const parts = path.split('.');
10
+ let current = obj;
11
+ for (let i = 0; i < parts.length - 1; i++) {
12
+ const key = parts[i];
13
+ if (!current[key] || typeof current[key] !== 'object')
14
+ current[key] = {};
15
+ current = current[key];
16
+ }
17
+ current[parts[parts.length - 1]] = value;
18
+ }
@@ -0,0 +1,3 @@
1
+ import slugify from 'slugify';
2
+ declare const _default: typeof slugify.default;
3
+ export default _default;
@@ -0,0 +1,4 @@
1
+ import slugify from 'slugify';
2
+ // `slugify` ships as CJS; mirror the interop cast used across the app so core
3
+ // code can import it without depending on `$lib/admin`.
4
+ export default slugify;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Duplicate a collection entry as an unpublished copy. Copies the freshest
3
+ * content per language (draft ?? published ?? scheduled), prefixes the admin
4
+ * title, makes each slug unique (`-kopia`), and creates draft (publishedAt:
5
+ * null) versions on a fresh entry. Media/relation references are shared
6
+ * (shallow). Throws on archived source. Returns the new entry id.
7
+ */
8
+ export declare function duplicateEntry(sourceId: string): Promise<{
9
+ id: string;
10
+ }>;
@@ -0,0 +1,73 @@
1
+ import slugify from '../../../fields/slugify.js';
2
+ import { getCMS } from '../../../cms.js';
3
+ import { getFieldsFromConfig } from '../../../fields/layoutUtils.js';
4
+ import { getAtPath, setAtPath } from '../../../fields/dotPath.js';
5
+ import { _getRawEntryOrThrow as getRawEntryOrThrow } from './get.js';
6
+ import { createEntryVersion } from './create.js';
7
+ import { collectSlugFieldPaths, deriveUniqueSlug } from './slugUniqueness.js';
8
+ const TITLE_PREFIX = 'Kopia — ';
9
+ /**
10
+ * Duplicate a collection entry as an unpublished copy. Copies the freshest
11
+ * content per language (draft ?? published ?? scheduled), prefixes the admin
12
+ * title, makes each slug unique (`-kopia`), and creates draft (publishedAt:
13
+ * null) versions on a fresh entry. Media/relation references are shared
14
+ * (shallow). Throws on archived source. Returns the new entry id.
15
+ */
16
+ export async function duplicateEntry(sourceId) {
17
+ const cms = getCMS();
18
+ const source = await getRawEntryOrThrow({ id: sourceId });
19
+ if (source.archivedAt != null) {
20
+ throw new Error('Cannot duplicate an archived entry');
21
+ }
22
+ const config = cms.getBySlug(source.slug);
23
+ const slugPaths = collectSlugFieldPaths(getFieldsFromConfig(config));
24
+ const titlePath = 'entryAdminTitle' in config ? config.entryAdminTitle : undefined;
25
+ // Languages that have any content; freshest version wins per language.
26
+ const langs = new Set([
27
+ ...Object.keys(source.draftVersions ?? {}),
28
+ ...Object.keys(source.publishedVersions ?? {}),
29
+ ...Object.keys(source.scheduledVersions ?? {})
30
+ ]);
31
+ const perLang = {};
32
+ for (const lang of langs) {
33
+ const version = source.draftVersions?.[lang] ??
34
+ source.publishedVersions?.[lang] ??
35
+ source.scheduledVersions?.[lang] ??
36
+ null;
37
+ if (!version)
38
+ continue;
39
+ perLang[lang] = structuredClone(version.data);
40
+ }
41
+ // Transform each copy: title prefix + unique, slugified slug.
42
+ for (const [lang, data] of Object.entries(perLang)) {
43
+ if (titlePath) {
44
+ const title = getAtPath(data, titlePath);
45
+ if (typeof title === 'string' && title.trim() !== '') {
46
+ setAtPath(data, titlePath, `${TITLE_PREFIX}${title}`);
47
+ }
48
+ }
49
+ for (const path of slugPaths) {
50
+ const value = getAtPath(data, path);
51
+ if (typeof value === 'string' && value.trim() !== '') {
52
+ const base = slugify(value, { lower: true, strict: true, trim: true });
53
+ const unique = await deriveUniqueSlug({ collection: source.slug, lang, path, base });
54
+ setAtPath(data, path, unique);
55
+ }
56
+ }
57
+ }
58
+ const newEntry = await cms.databaseAdapter.createEntry({
59
+ type: source.type,
60
+ slug: source.slug
61
+ });
62
+ const entries = Object.entries(perLang);
63
+ if (entries.length === 0) {
64
+ // Source had no content — seed an empty default-language draft.
65
+ await createEntryVersion({ entryId: newEntry.id, lang: cms.languages[0], data: {}, publishedAt: null }, { skipValidation: true });
66
+ }
67
+ else {
68
+ for (const [lang, data] of entries) {
69
+ await createEntryVersion({ entryId: newEntry.id, lang, data, publishedAt: null }, { skipValidation: true });
70
+ }
71
+ }
72
+ return { id: newEntry.id };
73
+ }
@@ -0,0 +1,8 @@
1
+ import type { CollectionConfigWithType } from '../../../../types/collections.js';
2
+ import type { SingleConfigWithType } from '../../../../types/singles.js';
3
+ /**
4
+ * Return a copy of `data` where every slug-typed field (SlugField / seo.slug /
5
+ * nested) is normalized through slugify. Non-string / empty values and non-slug
6
+ * fields are left untouched. Does not mutate the input.
7
+ */
8
+ export declare function normalizeSlugFields(config: CollectionConfigWithType | SingleConfigWithType, data: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,20 @@
1
+ import slugify from '../../../fields/slugify.js';
2
+ import { getFieldsFromConfig } from '../../../fields/layoutUtils.js';
3
+ import { getAtPath, setAtPath } from '../../../fields/dotPath.js';
4
+ import { collectSlugFieldPaths } from './slugUniqueness.js';
5
+ /**
6
+ * Return a copy of `data` where every slug-typed field (SlugField / seo.slug /
7
+ * nested) is normalized through slugify. Non-string / empty values and non-slug
8
+ * fields are left untouched. Does not mutate the input.
9
+ */
10
+ export function normalizeSlugFields(config, data) {
11
+ const slugPaths = collectSlugFieldPaths(getFieldsFromConfig(config));
12
+ const out = structuredClone(data);
13
+ for (const path of slugPaths) {
14
+ const value = getAtPath(out, path);
15
+ if (typeof value === 'string' && value.trim() !== '') {
16
+ setAtPath(out, path, slugify(value, { lower: true, strict: true, trim: true }));
17
+ }
18
+ }
19
+ return out;
20
+ }
@@ -35,3 +35,20 @@ export interface AssertSlugUniqueArgs {
35
35
  * which slug-field collided).
36
36
  */
37
37
  export declare function assertSlugUnique({ collection, lang, data, excludeEntryId }: AssertSlugUniqueArgs): Promise<void>;
38
+ export interface DeriveUniqueSlugArgs {
39
+ /** Collection slug (config slug). */
40
+ collection: string;
41
+ /** Content language whose slugs must not collide. */
42
+ lang: string;
43
+ /** Dot-path of the slug field. */
44
+ path: string;
45
+ /** Already-slugified base value to build the copy from. */
46
+ base: string;
47
+ }
48
+ /**
49
+ * Build a slug based on `base` that does not collide with any published-or-
50
+ * scheduled slug at `path` in `collection` + `lang`. Always appends the
51
+ * `-kopia` marker; on further collision appends `-kopia-2`, `-kopia-3`, … .
52
+ * Drafts, archived entries and other languages are ignored.
53
+ */
54
+ export declare function deriveUniqueSlug({ collection, lang, path, base }: DeriveUniqueSlugArgs): Promise<string>;
@@ -114,3 +114,42 @@ export async function assertSlugUnique({ collection, lang, data, excludeEntryId
114
114
  }
115
115
  }
116
116
  }
117
+ /**
118
+ * Build a slug based on `base` that does not collide with any published-or-
119
+ * scheduled slug at `path` in `collection` + `lang`. Always appends the
120
+ * `-kopia` marker; on further collision appends `-kopia-2`, `-kopia-3`, … .
121
+ * Drafts, archived entries and other languages are ignored.
122
+ */
123
+ export async function deriveUniqueSlug({ collection, lang, path, base }) {
124
+ const cms = getCMS();
125
+ const dbEntries = await cms.databaseAdapter.getEntries({ slug: collection });
126
+ const ids = dbEntries.filter((e) => e.archivedAt == null).map((e) => e.id);
127
+ const used = new Set();
128
+ if (ids.length > 0) {
129
+ const versions = await cms.databaseAdapter.getEntryVersions({ entryIds: ids, lang });
130
+ const latestPerEntry = new Map();
131
+ for (const v of versions) {
132
+ if (v.publishedAt == null)
133
+ continue;
134
+ const cur = latestPerEntry.get(v.entryId);
135
+ if (!cur || v.versionNumber > cur.versionNumber) {
136
+ latestPerEntry.set(v.entryId, {
137
+ versionNumber: v.versionNumber,
138
+ data: v.data
139
+ });
140
+ }
141
+ }
142
+ for (const { data } of latestPerEntry.values()) {
143
+ const s = readSlugAtPath(data, path, lang);
144
+ if (typeof s === 'string' && s.trim() !== '')
145
+ used.add(s);
146
+ }
147
+ }
148
+ let candidate = `${base}-kopia`;
149
+ let n = 2;
150
+ while (used.has(candidate)) {
151
+ candidate = `${base}-kopia-${n}`;
152
+ n++;
153
+ }
154
+ return candidate;
155
+ }
@@ -0,0 +1,2 @@
1
+ import type { CmsUpdate } from '../index.js';
2
+ export declare const update: CmsUpdate;
@@ -0,0 +1,12 @@
1
+ export const update = {
2
+ version: '0.36.9',
3
+ date: '2026-07-02',
4
+ description: 'Duplikacja wpisu z tabeli kolekcji + serwerowa normalizacja pól slug przy zapisie. Menu wiersza zyskuje „Duplikuj" — tworzy nieopublikowany szkic z tą samą treścią (wszystkie języki), z prefiksem tytułu „Kopia — " i unikalnym slugiem („-kopia"), i przenosi do edytora duplikatu. Media i relacje są współdzielone przez referencje (bez głębokiego klonu). Niezależnie od duplikacji: każde pole typu slug (SlugField oraz slug w polu SEO) jest przy zapisie normalizowane przez slugify — „Singapur"/„Hello World" zapisują się jako „singapur"/„hello-world", transparentnie dla użytkownika. Spec: `docs/superpowers/specs/2026-07-02-duplicate-entry-and-slug-normalization-design.md`, plan: `docs/superpowers/plans/2026-07-02-duplicate-entry-and-slug-normalization.md`.',
5
+ features: [
6
+ 'Duplikacja wpisu (`duplicateEntry` + remote `duplicateEntryCommand`, akcja „Duplikuj" w menu wiersza tabeli kolekcji `/admin/collections/[collection]`): kopiuje najświeższą treść per język (draft ?? published ?? scheduled) na nowy wpis jako wersje nieopublikowane (`publishedAt: null`), prefiksuje `entryAdminTitle` przez „Kopia — " i nadaje każdemu polu slug unikalny sufiks („-kopia", „-kopia-2", …) sprawdzany wobec published-or-scheduled slugów kolekcji, po czym przenosi do edytora duplikatu. Media/relacje = współdzielone referencje. Akcja ukryta dla wpisów archiwalnych.'
7
+ ],
8
+ fixes: [
9
+ 'Normalizacja slug przy zapisie (`normalizeSlugFields` wpięte w `updateEntryVersionCommand`, ścieżka draft i publish): wartości pól typu slug (SlugField oraz slug w polu SEO) są normalizowane przez `slugify({ lower, strict, trim })` przed zapisem i przed kontrolą unikalności. Nietechniczny użytkownik wpisujący np. „Singapur" czy „Hello World" dostaje poprawny „singapur"/„hello-world" bez ręcznej korekty. Puste/nie-string i pola nie-slug pozostają nietknięte.'
10
+ ],
11
+ breakingChanges: []
12
+ };
@@ -74,6 +74,7 @@ import { update as update0364 } from './0.36.4/index.js';
74
74
  import { update as update0365 } from './0.36.5/index.js';
75
75
  import { update as update0367 } from './0.36.7/index.js';
76
76
  import { update as update0368 } from './0.36.8/index.js';
77
+ import { update as update0369 } from './0.36.9/index.js';
77
78
  export const updates = [
78
79
  update0065,
79
80
  update0066,
@@ -150,7 +151,8 @@ export const updates = [
150
151
  update0364,
151
152
  update0365,
152
153
  update0367,
153
- update0368
154
+ update0368,
155
+ update0369
154
156
  ];
155
157
  export const getUpdatesFrom = (fromVersion) => {
156
158
  const fromParts = fromVersion.split('.').map(Number);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "includio-cms",
3
- "version": "0.36.8",
3
+ "version": "0.36.9",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",