All files / lib/schemas metadata.js

96.61% Statements 257/266
75% Branches 9/12
75% Functions 6/8
96.61% Lines 257/266

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 2911x 1x 1x 1x 1x   1x 1x 1x 1x 1x 42x 42x   1x 1x 1x 1x 10x 10x 10x 10x 10x 10x   1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x   1x 1x 1x 1x 1x 1x 1x 1x 1x 1x   1x 1x 1x   1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x   1x 1x 1x 1x 1x 1x 1x 1x 1x 1x   1x 1x 1x 1x 1x   1x 1x 44x 44x 44x 44x 1x 1x 1x 1x 1x 1x 1x 1x 1x   1x 1x 1x   1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x   1x 1x 1x 1x 1x 1x 1x 1x   1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x   1x   1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x   1x 1x 1x 1x   1x 1x 1x   1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x   1x 1x 1x 1x 1x 1x 1x 1x 1x 21x 21x 21x   1x 1x 1x 1x 1x 1x 3x       1x 1x 1x 1x 1x 1x 1x 1x 3x 3x   1x 1x 1x 1x 1x 1x       1x 1x 1x 1x 1x 1x 3x 3x 3x 3x   1x 1x 1x 1x 1x            
import { scope, type } from 'arktype';
import { parseISOSafe } from '../date.js';
import { EXIF_FIELDS } from '../exiffields.js';
import { keys, unique } from '../utils.js';
import { HTTPRequest, ID, ModelInput, Probability, URLString } from './common.js';
 
/**
 * @param {string} metadataId
 * @param {import('$lib/metadata').RuntimeValue} key
 */
export function metadataOptionId(metadataId, key) {
	return `${metadataId}:${key}`;
}
 
/**
 * @param {string} optionId
 */
export function parseMetadataOptionId(optionId) {
	const parts = optionId.split(':');
	if (parts.length < 2) throw new Error(`Invalid metadata option ID: ${optionId}`);
	const metadataId = parts.slice(0, -1).join(':');
	const key = parts[parts.length - 1];
	return { metadataId, key };
}
 
/**
 * @satisfies { Record<string, {label: string, help: string}> }
 */
export const METADATA_TYPES = /** @type {const} */ ({
	string: { label: 'texte', help: 'du texte' },
	boolean: { label: 'booléen', help: 'vrai ou faux' },
	integer: { label: 'entier', help: 'un entier' },
	float: { label: 'nombre', help: 'un nombre, potentiellement à virgule' },
	enum: { label: 'énumération', help: 'un ensemble de valeur fixes' },
	date: { label: 'date', help: 'une date' },
	location: {
		label: 'localisation',
		help: 'un objet avec deux nombres, `latitude` et `longitude`'
	},
	boundingbox: {
		label: "région d'image",
		help: 'un objet représentant une région rectangulaire au format YOLO'
	}
});
 
export const MetadataType = type.or(
	type("'string'", '@', METADATA_TYPES.string.help),
	type("'boolean'", '@', METADATA_TYPES.boolean.help),
	type("'integer'", '@', METADATA_TYPES.integer.help),
	type("'float'", '@', METADATA_TYPES.float.help),
	type("'enum'", '@', METADATA_TYPES.enum.help),
	type("'date'", '@', METADATA_TYPES.date.help),
	type("'location'", '@', METADATA_TYPES.location.help),
	type("'boundingbox'", '@', METADATA_TYPES.boundingbox.help)
);
 
/**
 * @typedef {typeof MetadataType.infer} MetadataType
 */
 
/**
 * @satisfies {{[ key in MetadataType ]: import('arktype').Type }}
 */
export const MetadataRuntimeValue = /** @type {const} */ ({
	string: type('string'),
	boolean: type('boolean'),
	integer: type('number'),
	float: type('number'),
	enum: type('string'),
	date: type('Date'),
	location: type({ latitude: 'number', longitude: 'number' }),
	boundingbox: type({ x: 'number', y: 'number', w: 'number', h: 'number' })
});
 
export const MetadataRuntimeValueAny = type.or(
	MetadataRuntimeValue.string,
	MetadataRuntimeValue.boolean,
	MetadataRuntimeValue.integer,
	MetadataRuntimeValue.float,
	MetadataRuntimeValue.enum,
	MetadataRuntimeValue.date,
	MetadataRuntimeValue.location,
	MetadataRuntimeValue.boundingbox
);
 
/**
 * @template {MetadataType} [Type=MetadataType]
 * @typedef  RuntimeValue
 * @type { typeof MetadataRuntimeValue[Type]['infer'] }
 */
 
export const MetadataValue = type({
	value: type('string.json').pipe((jsonstring) => {
		/** @type {import('../metadata').RuntimeValue<typeof MetadataType.infer>}  */
		let out = JSON.parse(jsonstring);
		if (typeof out === 'string') out = parseISOSafe(out) ?? out;
		return out;
	}, MetadataRuntimeValueAny),
	confidence: Probability.default(1),
	manuallyModified: type('boolean')
		.describe('Si la valeur a été modifiée manuellement')
		.default(false),
	alternatives: {
		'[string.json]': Probability
	}
});
 
export const MetadataValues = scope({ ID }).type({
	'[ID]': MetadataValue
});
 
/**
 * @satisfies { Record<string, { label: string; help: string }> }
 */
export const METADATA_MERGE_METHODS = /** @type {const} */ ({
	min: {
		label: 'Minimum',
		help: "Choisir la valeur avec la meilleure confiance, et prendre la plus petite valeur en cas d'ambuiguité"
	},
	max: {
		label: 'Maximum',
		help: "Choisir la valeur avec la meilleure confiance, et prendre la plus grande valeur en cas d'ambuiguité"
	},
	average: {
		label: 'Moyenne',
		help: 'Prend la moyenne des valeurs'
	},
	median: {
		label: 'Médiane',
		help: 'Prend la médiane des valeurs'
	},
	union: {
		label: 'Union',
		help: 'Spécifique aux boîtes de recadrage: fusionne les boîtes de recadrage en la plus petite boîte englobant toutes les boîtes'
	},
	none: {
		label: 'Aucune',
		help: 'Ne pas fusionner'
	}
});
 
export const MetadataMergeMethod = type.or(
	type('"min"', '@', METADATA_MERGE_METHODS.min.help),
	type('"max"', '@', METADATA_MERGE_METHODS.max.help),
	type('"average"', '@', METADATA_MERGE_METHODS.average.help),
	type('"median"', '@', METADATA_MERGE_METHODS.median.help),
	type('"none"', '@', METADATA_MERGE_METHODS.none.help),
	type('"union"', '@', METADATA_MERGE_METHODS.union.help)
);
 
export const MetadataEnumVariant = type({
	key: [ID, '@', 'Identifiant unique pour cette option'],
	label: ['string', '@', "Nom de l'option, affichable dans une interface utilisateur"],
	description: ['string', '@', 'Description (optionnelle) de cette option'],
	'image?': URLString,
	'learnMore?': URLString.describe(
		"Lien pour en savoir plus sur cette option de l'énumération en particulier"
	),
	'cascade?': scope({ ID })
		.type({ '[ID]': 'ID' })
		.describe(
			'Objet contenant pour clés des identifiants d\'autres métadonnées, et pour valeurs la valeur à assigner à cette métadonnée si cette option est choisie. Le processus est récursif: Imaginons une métadonnée species ayant une option avec `{ key: "1", cascade: { genus: "2" } }`, une métadonnée genus ayant une option `{ key: "2", cascade: { family: "3" } }`. Si l\'option "1" de la métadonnée species est choisie, la métadonnée genus sera définie sur l\'option "2" et la métadonnée family sera à son tour définie sur l\'option "3".'
		)
});
 
export const EXIFField = type.enumerated(...keys(EXIF_FIELDS));
 
export const MetadataInferOptionsNeural = type({
	neural: type({
		model: HTTPRequest.describe(
			'Lien vers le modèle de classification utilisé pour inférer les métadonnées. Au format ONNX (.onnx) seulement, pour le moment.'
		),
		classmapping: HTTPRequest.describe(
			'Fichier texte contenant une clé de la métadonnée par ligne, dans le même ordre que les neurones de sortie du modèle.'
		),
		'name?': [
			'string',
			'@',
			"Nom du réseau à afficher dans l'interface. Particulièrement utile si il y a plusieurs réseaux"
		],
		input: ModelInput.describe("Configuration de l'entrée des modèles"),
		'output?': type({
			'name?': ['string', '@', "Nom de l'output du modèle à utiliser. output0 par défaut"]
		})
	}).array()
}).describe('Inférer depuis un modèle de réseau de neurones', 'self');
 
export const MetadataInferOptionsEXIF = type({ exif: EXIFField }).describe(
	'Inférer depuis un champ EXIF',
	'self'
);
 
export const MetadataInferOptions = type
	.or(MetadataInferOptionsEXIF, MetadataInferOptionsNeural)
	.describe('Comment inférer la valeur de cette métadonnée', 'self');
 
export const Metadata = type({
	id: ID.describe(
		'Identifiant unique pour la métadonnée. On conseille de mettre une partie qui vous identifie dans cet identifiant, car il doit être globalement unique. Par exemple, mon-organisation.ma-métadonnée'
	),
	label: ['string', '@', 'Nom de la métadonnée'],
	mergeMethod: MetadataMergeMethod.configure(
		"Méthode utiliser pour fusionner plusieurs différentes valeurs d'une métadonnée. Notamment utilisé pour calculer la valeur d'une métadonnée sur une Observation à partir de ses images",
		'self'
	),
	required: ['boolean', '@', 'Si la métadonnée est obligatoire'],
	description: ['string', '@', 'Description, pour aider à comprendre la métadonnée'],
	learnMore: URLString.describe(
		'Un lien pour en apprendre plus sur ce que cette métadonnée décrit'
	).optional(),
	'options?': MetadataEnumVariant.array()
		.pipe((opts) => unique(opts, (o) => o.key))
		.describe('Les options valides. Uniquement utile pour une métadonnée de type "enum"')
}).and(
	type.or(
		{
			type: "'location'",
			'infer?': { latitude: MetadataInferOptions, longitude: MetadataInferOptions }
		},
		{
			type: MetadataType.exclude('"location"'),
			'infer?': MetadataInferOptions
		}
	)
);
 
/**
 * Ensures a metadata ID is namespaced to the given protocol ID
 * If the ID is already namespaced, the existing namespace is re-namespaced to the given protocol ID.
 * @template {string} ProtocolID
 * @param {ProtocolID} protocolId
 * @param {string} metadataId
 * @returns {`${ProtocolID}__${string}`}
 */
export function namespacedMetadataId(protocolId, metadataId) {
	metadataId = metadataId.replace(/^.+__/, '');
	return `${protocolId}__${metadataId}`;
}
 
/**
 * Ensures a metadata ID is namespaced to the given protocol ID. If the metadata ID is not namespaced, it will be prefixed with the protocol ID. If it already is namespaced, it will stay as is.
 * @param {string} metadataId the metadata ID to ensure is namespaced
 * @param {string} fallbackProtocolId the protocol ID to use if the metadata ID is not namespaced
 */
export function ensureNamespacedMetadataId(metadataId, fallbackProtocolId) {
	if (isNamespacedToProtocol(fallbackProtocolId, metadataId)) return metadataId;
	return namespacedMetadataId(fallbackProtocolId, metadataId);
}
 
/**
 * Checks if a given metadata ID is namespaced to a given protocol ID
 * @template {string} ProtocolID
 * @param {ProtocolID} protocolId
 * @param {string} metadataId
 * @returns {metadataId is `${ProtocolID}__${string}` }
 */
export function isNamespacedToProtocol(protocolId, metadataId) {
	return metadataId.startsWith(`${protocolId}__`);
}
 
/**
 *
 * @param {string} metadataId
 * @returns {string}
 */
export function removeNamespaceFromMetadataId(metadataId) {
	return metadataId.replace(/^.+__/, '');
}
 
/**
 *
 * @param {string} metadataId
 * @returns
 */
export function namespaceOfMetadataId(metadataId) {
	const parts = metadataId.split('__');
	if (parts.length < 2) return undefined;
	return parts.slice(0, -1).join('__');
}
 
/**
 *
 * @param {string} metadataId
 */
export function splitMetadataId(metadataId) {
	return {
		namespace: namespaceOfMetadataId(metadataId),
		id: removeNamespaceFromMetadataId(metadataId)
	};
}