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 +3 -2
- package/CHANGELOG.md +10 -0
- package/DOCS.md +1 -1
- package/dist/admin/client/collection/collection-entries.svelte +20 -1
- package/dist/admin/client/collection/row-actions.svelte +12 -4
- package/dist/admin/client/collection/row-actions.svelte.d.ts +1 -0
- package/dist/admin/remote/entry.remote.d.ts +3 -0
- package/dist/admin/remote/entry.remote.js +14 -3
- package/dist/core/fields/dotPath.d.ts +4 -0
- package/dist/core/fields/dotPath.js +18 -0
- package/dist/core/fields/slugify.d.ts +3 -0
- package/dist/core/fields/slugify.js +4 -0
- package/dist/core/server/entries/operations/duplicate.d.ts +10 -0
- package/dist/core/server/entries/operations/duplicate.js +73 -0
- package/dist/core/server/entries/operations/slugNormalize.d.ts +8 -0
- package/dist/core/server/entries/operations/slugNormalize.js +20 -0
- package/dist/core/server/entries/operations/slugUniqueness.d.ts +17 -0
- package/dist/core/server/entries/operations/slugUniqueness.js +39 -0
- package/dist/updates/0.36.9/index.d.ts +2 -0
- package/dist/updates/0.36.9/index.js +12 -0
- package/dist/updates/index.js +3 -1
- package/package.json +1 -1
package/API.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# includio-cms — Public API v0.36.
|
|
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: **
|
|
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
|
@@ -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
|
|
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" />
|
|
@@ -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
|
|
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,
|
|
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,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,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
|
+
};
|
package/dist/updates/index.js
CHANGED
|
@@ -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);
|