domma-cms 0.77.2 → 0.78.0
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/package.json
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn the CHANGELOG's `## [Unreleased]` heading into `## [X.Y.Z] - YYYY-MM-DD`.
|
|
3
|
+
*
|
|
4
|
+
* The release notes are the one part of a release a workflow cannot write, so
|
|
5
|
+
* they are written as the work lands, under `[Unreleased]`, and this is what
|
|
6
|
+
* the "Cut a release" workflow does with them. Refusing to run without them is
|
|
7
|
+
* the point: a release with no entry is a release nobody can read about.
|
|
8
|
+
*
|
|
9
|
+
* With `--print X.Y.Z` it instead prints an already-released section.
|
|
10
|
+
*
|
|
11
|
+
* The same script as domma-reactive/scripts/promote-changelog.mjs; both
|
|
12
|
+
* CHANGELOGs follow Keep a Changelog. Keep the two in step.
|
|
13
|
+
*
|
|
14
|
+
* node scripts/promote-changelog.mjs 1.2.0 2026-10-01
|
|
15
|
+
* node scripts/promote-changelog.mjs --print 1.2.0
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {readFileSync, writeFileSync} from 'node:fs';
|
|
19
|
+
import {fileURLToPath} from 'node:url';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
const FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'CHANGELOG.md');
|
|
23
|
+
|
|
24
|
+
const fail = (message) => {
|
|
25
|
+
console.error(`\n promote-changelog: ${message}\n`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
30
|
+
|
|
31
|
+
/** The body under a `## [label]` heading, up to the next `## [` heading. */
|
|
32
|
+
function section(source, label) {
|
|
33
|
+
const heading = new RegExp(`^## \\[${escape(label)}\\][^\\n]*\\n`, 'm');
|
|
34
|
+
const match = heading.exec(source);
|
|
35
|
+
if (!match) return null;
|
|
36
|
+
|
|
37
|
+
const start = match.index + match[0].length;
|
|
38
|
+
const next = source.slice(start).search(/^## \[/m);
|
|
39
|
+
const end = next === -1 ? source.length : start + next;
|
|
40
|
+
return {heading: match, start, end, body: source.slice(start, end).trim()};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const source = readFileSync(FILE, 'utf8');
|
|
44
|
+
|
|
45
|
+
if (process.argv[2] === '--print') {
|
|
46
|
+
const version = process.argv[3];
|
|
47
|
+
const found = version && section(source, version);
|
|
48
|
+
if (!found) fail(`no '## [${version}]' section in CHANGELOG.md`);
|
|
49
|
+
console.log(found.body);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const [version, date] = process.argv.slice(2);
|
|
54
|
+
if (!/^\d+\.\d+\.\d+$/.test(version || '')) fail('usage: promote-changelog.mjs X.Y.Z YYYY-MM-DD');
|
|
55
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date || '')) fail(`"${date}" is not a YYYY-MM-DD date`);
|
|
56
|
+
|
|
57
|
+
if (section(source, version)) fail(`CHANGELOG.md already has a '## [${version}]' section`);
|
|
58
|
+
|
|
59
|
+
const pending = section(source, 'Unreleased');
|
|
60
|
+
if (!pending) {
|
|
61
|
+
fail("no '## [Unreleased]' section - write the release notes there first, then cut the release");
|
|
62
|
+
}
|
|
63
|
+
if (pending.body === '') {
|
|
64
|
+
fail("'## [Unreleased]' is empty - write the release notes there first, then cut the release");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
writeFileSync(
|
|
68
|
+
FILE,
|
|
69
|
+
source.slice(0, pending.heading.index) + `## [${version}] - ${date}\n` + source.slice(pending.start)
|
|
70
|
+
);
|
|
71
|
+
console.log(`\n CHANGELOG.md: [Unreleased] → [${version}] - ${date}\n`);
|
|
@@ -12,6 +12,7 @@ import {v4 as uuidv4} from 'uuid';
|
|
|
12
12
|
import {config} from '../config.js';
|
|
13
13
|
import {getAdapter, invalidate} from './adapterRegistry.js';
|
|
14
14
|
import * as cache from './cache/index.js';
|
|
15
|
+
import {typedEntryData} from './entryValues.js';
|
|
15
16
|
import {toCsv} from '../../public/js/collection-export.mjs';
|
|
16
17
|
|
|
17
18
|
const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
|
|
@@ -388,6 +389,7 @@ export async function getEntry(slug, entryId) {
|
|
|
388
389
|
export async function createEntry(slug, data, { createdBy = null, source = 'admin' } = {}) {
|
|
389
390
|
const schema = await getCollection(slug);
|
|
390
391
|
if (!schema) throw new Error(`Collection "${slug}" not found`);
|
|
392
|
+
data = typedEntryData(schema, data);
|
|
391
393
|
|
|
392
394
|
const { valid, errors } = validateEntryData(schema, data);
|
|
393
395
|
if (!valid) throw new Error(`Validation failed: ${errors.join('; ')}`);
|
|
@@ -420,6 +422,7 @@ export async function createEntry(slug, data, { createdBy = null, source = 'admi
|
|
|
420
422
|
export async function updateEntry(slug, entryId, data) {
|
|
421
423
|
const schema = await getCollection(slug);
|
|
422
424
|
if (!schema) throw new Error(`Collection "${slug}" not found`);
|
|
425
|
+
data = typedEntryData(schema, data);
|
|
423
426
|
|
|
424
427
|
const { valid, errors } = validateEntryData(schema, data);
|
|
425
428
|
if (!valid) throw new Error(`Validation failed: ${errors.join('; ')}`);
|
|
@@ -526,7 +529,7 @@ export async function importEntries(slug, incoming, { createdBy = null } = {}) {
|
|
|
526
529
|
const valid = [];
|
|
527
530
|
|
|
528
531
|
for (const item of incoming) {
|
|
529
|
-
const data = item.data || item;
|
|
532
|
+
const data = typedEntryData(schema, item.data || item);
|
|
530
533
|
const { valid: ok, errors: valErrors } = validateEntryData(schema, data);
|
|
531
534
|
if (!ok) {
|
|
532
535
|
skipped++;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry values as their field types store them.
|
|
3
|
+
*
|
|
4
|
+
* Everything that writes entries through the collections service - the admin,
|
|
5
|
+
* the external /api/v1 API, form submissions, import, Actions - arrives here
|
|
6
|
+
* first, so a `number` field sent as text ("42", as HTML forms and CSV-minded
|
|
7
|
+
* clients send it) is stored as a number and sorts, filters and totals as one.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately a conversion, not a check: text that is not a plain decimal
|
|
10
|
+
* number ("", "abc", "0x10", "Infinity") is stored as sent, exactly as before,
|
|
11
|
+
* so no existing client starts getting errors. Only top-level `number` fields.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const DECIMAL = /^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
|
|
15
|
+
|
|
16
|
+
/** "42" -> 42; anything that is not plain decimal text is returned unchanged. */
|
|
17
|
+
export function numberFromText(value) {
|
|
18
|
+
if (typeof value !== 'string') return value;
|
|
19
|
+
const t = value.trim();
|
|
20
|
+
if (!DECIMAL.test(t)) return value;
|
|
21
|
+
const n = Number(t);
|
|
22
|
+
return Number.isFinite(n) ? n : value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The schema's number field names. */
|
|
26
|
+
export function numberFieldNames(schema) {
|
|
27
|
+
return (schema?.fields || []).filter(f => f?.type === 'number' && f.name).map(f => f.name);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `data` with its number fields converted - a new object when anything changed,
|
|
32
|
+
* the same object when nothing did (the caller's object is never modified).
|
|
33
|
+
*
|
|
34
|
+
* @param {object} data
|
|
35
|
+
* @param {string[]} names - from numberFieldNames()
|
|
36
|
+
*/
|
|
37
|
+
export function withNumbers(data, names) {
|
|
38
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) || !names?.length) return data;
|
|
39
|
+
let out = data;
|
|
40
|
+
for (const name of names) {
|
|
41
|
+
if (!Object.prototype.hasOwnProperty.call(data, name)) continue;
|
|
42
|
+
const next = numberFromText(data[name]);
|
|
43
|
+
if (next !== data[name]) {
|
|
44
|
+
if (out === data) out = {...data};
|
|
45
|
+
out[name] = next;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** An entry's data typed for its collection's schema. */
|
|
52
|
+
export function typedEntryData(schema, data) {
|
|
53
|
+
return withNumbers(data, numberFieldNames(schema));
|
|
54
|
+
}
|
|
@@ -13,25 +13,18 @@
|
|
|
13
13
|
* an edit.
|
|
14
14
|
*
|
|
15
15
|
* Runs ONCE per install: the marker (content/.migrations.json) is written only
|
|
16
|
-
* when every collection was done.
|
|
17
|
-
*
|
|
16
|
+
* when every collection was done. From 0.78.0 the collections service converts
|
|
17
|
+
* on every write (entryValues.js), so nothing new of this kind arrives - this
|
|
18
|
+
* only mends what was stored before.
|
|
18
19
|
*/
|
|
19
20
|
import fs from 'fs/promises';
|
|
20
21
|
import path from 'path';
|
|
21
22
|
import {config} from '../config.js';
|
|
23
|
+
import {numberFieldNames, numberFromText, withNumbers} from './entryValues.js';
|
|
22
24
|
|
|
23
25
|
export const MIGRATION_ID = 'number-field-types';
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
/** "42" -> 42; anything that is not plain decimal text is returned unchanged. */
|
|
28
|
-
export function numberFromText(value) {
|
|
29
|
-
if (typeof value !== 'string') return value;
|
|
30
|
-
const t = value.trim();
|
|
31
|
-
if (!DECIMAL.test(t)) return value;
|
|
32
|
-
const n = Number(t);
|
|
33
|
-
return Number.isFinite(n) ? n : value;
|
|
34
|
-
}
|
|
27
|
+
export {numberFromText};
|
|
35
28
|
|
|
36
29
|
/**
|
|
37
30
|
* An entry with its number fields converted, or null when nothing changed.
|
|
@@ -42,13 +35,8 @@ export function numberFromText(value) {
|
|
|
42
35
|
export function convertEntry(entry, names) {
|
|
43
36
|
const data = entry?.data;
|
|
44
37
|
if (!data || typeof data !== 'object') return null;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (!(name in data)) continue;
|
|
48
|
-
const next = numberFromText(data[name]);
|
|
49
|
-
if (next !== data[name]) (changed ||= {...data})[name] = next;
|
|
50
|
-
}
|
|
51
|
-
return changed ? {...entry, data: changed} : null;
|
|
38
|
+
const next = withNumbers(data, names);
|
|
39
|
+
return next === data ? null : {...entry, data: next};
|
|
52
40
|
}
|
|
53
41
|
|
|
54
42
|
function defaultMarkerPath() {
|
|
@@ -79,7 +67,7 @@ export async function migrateNumberFields({markerPath = defaultMarkerPath(), for
|
|
|
79
67
|
const converted = {};
|
|
80
68
|
const pending = [];
|
|
81
69
|
for (const schema of await listCollections()) {
|
|
82
|
-
const names = (schema
|
|
70
|
+
const names = numberFieldNames(schema);
|
|
83
71
|
if (!names.length) continue;
|
|
84
72
|
try {
|
|
85
73
|
const adapter = await getAdapter(schema.slug);
|