export type DurationUnit = 'days' | 'months' | 'years'; export type ColumnRuleOperator = 'lessThan' | 'lessOrEqual' | 'equal' | 'greaterOrEqual' | 'greaterThan'; export type ExpiryLogicalOperator = 'and' | 'or'; export interface IExpiryDuration { value: number; unit: DurationUnit; } export interface IExpiryColumnRule { daysUntilExpiry: number; operator: ColumnRuleOperator; backgroundColor: string; textColor: string; label: string; } export interface IExpiryBehavior { lifeTime: IExpiryDuration; columnRule: IExpiryColumnRule[]; } export interface IExpiryFieldCondition { columnName: string; columnValue: string; } export interface IExpiryConditionGroup { operator: ExpiryLogicalOperator; conditions: IExpiryCondition[]; } export type IExpiryCondition = IExpiryFieldCondition | IExpiryConditionGroup; export interface IExpiryValueRule extends IExpiryBehavior { // Version 1 compatibility. New rules should use condition. columnName?: string; columnValue?: string; condition?: IExpiryConditionGroup; } export interface IExpiryConfig { baseField: string; expiryField: string; default: IExpiryBehavior; rules: IExpiryValueRule[]; nullText: string; confirmExtension: boolean; } export interface IExpiryEvaluation { effectiveExpiryDate: Date; daysUntilExpiry: number; matchedRule?: IExpiryColumnRule; wasCalculated: boolean; } export interface IItemDateValues { id: number; created: Date; expiry?: Date; fieldValues: { [fieldName: string]: any }; } export interface IUpdateResult { id: number; succeeded: boolean; newExpiryDate?: Date; error?: string; } export function createDefaultConfig(): IExpiryConfig { return { baseField: 'Created', expiryField: 'ExpiryDate', default: { lifeTime: { value: 2, unit: 'years' }, columnRule: createDefaultColumnRules() }, rules: [], nullText: 'Kein Ablaufdatum', confirmExtension: true }; } export function normalizeConfig(value: any): IExpiryConfig { const defaults: IExpiryConfig = createDefaultConfig(); const config: any = value || {}; const defaultBehavior: any = config.default || {}; return { baseField: isSafeInternalName(config.baseField) ? config.baseField : defaults.baseField, expiryField: isSafeInternalName(config.expiryField) ? config.expiryField : defaults.expiryField, default: { lifeTime: normalizeDuration(defaultBehavior.lifeTime, defaults.default.lifeTime), columnRule: normalizeColumnRules(defaultBehavior.columnRule, defaults.default.columnRule) }, rules: normalizeValueRules(config.rules), nullText: typeof config.nullText === 'string' ? config.nullText : defaults.nullText, confirmExtension: typeof config.confirmExtension === 'boolean' ? config.confirmExtension : defaults.confirmExtension }; } export function getRuleFieldNames(config: IExpiryConfig): string[] { const fields: string[] = []; config.rules.forEach((rule: IExpiryValueRule): void => { if (rule.condition) { collectConditionFieldNames(rule.condition, fields); } else if (rule.columnName && isSafeInternalName(rule.columnName) && fields.indexOf(rule.columnName) < 0) { fields.push(rule.columnName); } }); return fields; } export function resolveBehavior( config: IExpiryConfig, fieldValues: { [fieldName: string]: any } ): IExpiryBehavior { for (let index: number = 0; index < config.rules.length; index++) { const rule: IExpiryValueRule = config.rules[index]; if (ruleMatches(rule, fieldValues)) { return { lifeTime: rule.lifeTime, columnRule: rule.columnRule }; } } return config.default; } export function isSafeInternalName(value: string): boolean { return typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value); } function createDefaultColumnRules(): IExpiryColumnRule[] { return [ { daysUntilExpiry: 0, operator: 'lessOrEqual', backgroundColor: '#a4262c', textColor: '#ffffff', label: 'Abgelaufen' }, { daysUntilExpiry: 30, operator: 'lessOrEqual', backgroundColor: '#ffaa44', textColor: '#000000', label: 'Läuft bald ab' }, { daysUntilExpiry: 90, operator: 'lessOrEqual', backgroundColor: '#fff4ce', textColor: '#000000', label: 'Beobachten' } ]; } function normalizeValueRules(value: any): IExpiryValueRule[] { if (!Array.isArray(value)) { return []; } const rules: IExpiryValueRule[] = []; value.forEach((rule: any): void => { if (!rule) { return; } const condition: IExpiryConditionGroup | undefined = normalizeConditionGroup(rule.condition, 0); const hasLegacyCondition: boolean = isSafeInternalName(rule.columnName) && typeof rule.columnValue !== 'undefined'; if (!condition && !hasLegacyCondition) { return; } const columnRules: IExpiryColumnRule[] = normalizeColumnRules(rule.columnRule, []); const normalizedRule: IExpiryValueRule = { lifeTime: normalizeDuration(rule.lifeTime, { value: 1, unit: 'years' }), columnRule: columnRules }; if (condition) { normalizedRule.condition = condition; } else { normalizedRule.columnName = rule.columnName; normalizedRule.columnValue = String(rule.columnValue); } rules.push(normalizedRule); }); return rules; } export function validateValueRules(value: any): string[] { if (!Array.isArray(value)) { return ['rules muss ein JSON-Array sein.']; } const errors: string[] = []; value.forEach((rule: any, index: number): void => { const path: string = 'rules[' + index + ']'; if (!rule || typeof rule !== 'object') { errors.push(path + ' muss ein Objekt sein.'); return; } const hasLegacyCondition: boolean = isSafeInternalName(rule.columnName) && typeof rule.columnValue !== 'undefined'; if (rule.condition) { validateConditionGroup(rule.condition, path + '.condition', 0, errors); } else if (!hasLegacyCondition) { errors.push(path + ' benötigt condition oder columnName/columnValue.'); } }); return errors; } function normalizeConditionGroup(value: any, depth: number): IExpiryConditionGroup | undefined { if (!value || depth > 10 || !isLogicalOperator(value.operator) || !Array.isArray(value.conditions) || value.conditions.length === 0) { return undefined; } const conditions: IExpiryCondition[] = []; value.conditions.forEach((condition: any): void => { if (condition && typeof condition.operator !== 'undefined') { const group: IExpiryConditionGroup | undefined = normalizeConditionGroup(condition, depth + 1); if (group) { conditions.push(group); } } else if (condition && isSafeInternalName(condition.columnName) && typeof condition.columnValue !== 'undefined') { conditions.push({ columnName: condition.columnName, columnValue: String(condition.columnValue) }); } }); return conditions.length > 0 ? { operator: value.operator, conditions: conditions } : undefined; } function validateConditionGroup(value: any, path: string, depth: number, errors: string[]): void { if (depth > 10) { errors.push(path + ' überschreitet die maximale Verschachtelungstiefe 10.'); return; } if (!value || typeof value !== 'object') { errors.push(path + ' muss ein Objekt sein.'); return; } if (!isLogicalOperator(value.operator)) { errors.push(path + '.operator muss "and" oder "or" sein.'); } if (!Array.isArray(value.conditions) || value.conditions.length === 0) { errors.push(path + '.conditions muss mindestens eine Bedingung enthalten.'); return; } value.conditions.forEach((condition: any, index: number): void => { const conditionPath: string = path + '.conditions[' + index + ']'; if (condition && typeof condition.operator !== 'undefined') { validateConditionGroup(condition, conditionPath, depth + 1, errors); } else if (!condition || !isSafeInternalName(condition.columnName) || typeof condition.columnValue === 'undefined') { errors.push(conditionPath + ' benötigt einen gültigen columnName und columnValue.'); } }); } function ruleMatches(rule: IExpiryValueRule, fieldValues: { [fieldName: string]: any }): boolean { if (rule.condition) { return conditionMatches(rule.condition, fieldValues); } return !!rule.columnName && typeof rule.columnValue !== 'undefined' && Object.prototype.hasOwnProperty.call(fieldValues, rule.columnName) && valueMatches(fieldValues[rule.columnName], rule.columnValue); } function conditionMatches(condition: IExpiryCondition, fieldValues: { [fieldName: string]: any }): boolean { if (isConditionGroup(condition)) { if (condition.operator === 'and') { return condition.conditions.every((child: IExpiryCondition): boolean => conditionMatches(child, fieldValues)); } return condition.conditions.some((child: IExpiryCondition): boolean => conditionMatches(child, fieldValues)); } return Object.prototype.hasOwnProperty.call(fieldValues, condition.columnName) && valueMatches(fieldValues[condition.columnName], condition.columnValue); } function collectConditionFieldNames(condition: IExpiryCondition, fields: string[]): void { if (isConditionGroup(condition)) { condition.conditions.forEach((child: IExpiryCondition): void => collectConditionFieldNames(child, fields)); } else if (fields.indexOf(condition.columnName) < 0) { fields.push(condition.columnName); } } function isConditionGroup(condition: IExpiryCondition): condition is IExpiryConditionGroup { return typeof (condition as IExpiryConditionGroup).operator !== 'undefined'; } function isLogicalOperator(value: any): value is ExpiryLogicalOperator { return value === 'and' || value === 'or'; } function normalizeDuration(value: any, fallback: IExpiryDuration): IExpiryDuration { if (!value || typeof value.value !== 'number' || !isFinite(value.value)) { return fallback; } if (value.unit !== 'days' && value.unit !== 'months' && value.unit !== 'years') { return fallback; } return { value: Math.floor(value.value), unit: value.unit }; } function normalizeColumnRules(value: any, fallback: IExpiryColumnRule[]): IExpiryColumnRule[] { if (!Array.isArray(value)) { return fallback; } const rules: IExpiryColumnRule[] = []; value.forEach((rule: any): void => { if (!rule || typeof rule.daysUntilExpiry !== 'number' || !isFinite(rule.daysUntilExpiry)) { return; } if (!isColumnRuleOperator(rule.operator) || !isColor(rule.backgroundColor) || !isColor(rule.textColor)) { return; } rules.push({ daysUntilExpiry: Math.floor(rule.daysUntilExpiry), operator: rule.operator, backgroundColor: rule.backgroundColor, textColor: rule.textColor, label: typeof rule.label === 'string' ? rule.label : '' }); }); return rules.length > 0 || fallback.length === 0 ? rules : fallback; } function valueMatches(rawValue: any, configuredValue: string): boolean { if (rawValue === null || typeof rawValue === 'undefined') { return configuredValue === ''; } let values: any[]; if (Array.isArray(rawValue)) { values = rawValue; } else if (rawValue.results && Array.isArray(rawValue.results)) { values = rawValue.results; } else { values = [rawValue]; } return values.some((value: any): boolean => { if (value && typeof value === 'object') { // SharePoint taxonomy values are not consistent across REST versions. In // SharePoint Server the Label may contain the WssId instead of the term // label, whereas TermGuid remains stable. if (typeof value.TermGuid === 'string' && equalsIgnoreCase(value.TermGuid, configuredValue)) { return true; } return String(value.Title || value.LookupValue || value.Label || value.Value || '') === configuredValue; } const textValue: string = String(value); if (textValue === configuredValue) { return true; } // Also support serialized taxonomy values such as "Label|TermGuid" and // "-1;#Label|TermGuid". const separatorIndex: number = textValue.lastIndexOf('|'); return separatorIndex >= 0 && equalsIgnoreCase(textValue.substring(separatorIndex + 1), configuredValue); }); } function equalsIgnoreCase(left: string, right: string): boolean { return left.toLowerCase() === right.toLowerCase(); } function isColumnRuleOperator(value: string): boolean { return value === 'lessThan' || value === 'lessOrEqual' || value === 'equal' || value === 'greaterOrEqual' || value === 'greaterThan'; } function isColor(value: string): boolean { return typeof value === 'string' && ( /^#[0-9a-fA-F]{3}$/.test(value) || /^#[0-9a-fA-F]{6}$/.test(value) || /^rgba?\([0-9.,% ]+\)$/.test(value) || /^[a-zA-Z]+$/.test(value) ); }