diff --git a/core-ts/package.json b/core-ts/package.json index 01960018..528a9cbb 100644 --- a/core-ts/package.json +++ b/core-ts/package.json @@ -13,12 +13,14 @@ "format": "prettier --write src/" }, "dependencies": { + "mustache": "^4.2.0", "vue": "^3.3.4" }, "devDependencies": { "@rushstack/eslint-patch": "^1.3.3", "@tsconfig/node18": "^18.2.2", "@types/jsdom": "^21.1.3", + "@types/mustache": "^4.2.3", "@types/node": "^18.17.17", "@vitejs/plugin-vue": "^4.3.4", "@vue/eslint-config-prettier": "^8.0.0", diff --git a/core-ts/src/App.vue b/core-ts/src/App.vue index d05208d6..3815e241 100644 --- a/core-ts/src/App.vue +++ b/core-ts/src/App.vue @@ -1,47 +1,80 @@ - - -.logo { - display: block; - margin: 0 auto 2rem; -} + +./components/core \ No newline at end of file diff --git a/core-ts/src/__tests__/cron.spec.ts b/core-ts/src/__tests__/cron.spec.ts new file mode 100644 index 00000000..045f9d89 --- /dev/null +++ b/core-ts/src/__tests__/cron.spec.ts @@ -0,0 +1,47 @@ +import { arrayToSegment, cronToSegment } from '@/cron' +import { FieldWrapper } from '@/types' +import { genItems } from '@/util' +import { describe, expect, it } from 'vitest' + +const r = (min: number, max: number) => { + return new FieldWrapper({ id: 'fieldId', items: genItems(min, max) }) +} + +describe('segments', () => { + it('cronToSegment', () => { + const cronToArray = (cron: string, field: FieldWrapper) => { + return cronToSegment(cron, field)?.toArray() ?? null + } + + expect(cronToArray('*', r(1, 3))).toEqual([]) + expect(cronToArray('1,3,5', r(0, 24))).toEqual([1, 3, 5]) + expect(cronToArray('*/5', r(0, 11))).toEqual([0, 5, 10]) + expect(cronToArray('*/5', r(1, 11))).toEqual([5, 10]) + expect(cronToArray('10-15', r(0, 59))).toEqual([10, 11, 12, 13, 14, 15]) + expect(cronToArray('10-11,20-22,30-33', r(0, 59))).toEqual([10, 11, 20, 21, 22, 30, 31, 32, 33]) + expect(cronToArray('5,7-8', r(0, 59))).toEqual([5, 7, 8]) + + expect(cronToArray('x', r(0, 59))).toBe(null) + expect(cronToArray('1-60', r(0, 59))).toBe(null) + expect(cronToArray('0-10', r(1, 59))).toBe(null) + expect(cronToArray('60', r(0, 59))).toBe(null) + expect(cronToArray('0', r(1, 10))).toBe(null) + expect(cronToArray('*/90', r(1, 10))).toBe(null) + }) + + it('arrayToSegment', () => { + const arrayToCron = (arr: number[], field: FieldWrapper) => { + return arrayToSegment(arr, field)?.toCron() ?? null + } + + expect(arrayToCron([1, 10], r(1, 10))).toEqual('1,10') + expect(arrayToCron([1, 2, 3], r(1, 10))).toEqual('1-3') + expect(arrayToCron([2, 4, 6], r(1, 10))).toEqual('2,4,6') + expect(arrayToCron([], r(1, 3))).toEqual('*') + expect(arrayToCron([1, 2, 3], r(1, 3))).toEqual('*') + expect(arrayToCron([0, 5, 10], r(0, 10))).toEqual('*/5') + expect(arrayToCron([7, 14, 21, 28], r(5, 30))).toEqual('*/7') + expect(arrayToCron([0, 5, 10], r(0, 20))).toEqual('0,5,10') + expect(arrayToCron([1, 2, 5, 8, 9, 10], r(1, 10))).toEqual('1-2,5,8-10') + }) +}) diff --git a/core-ts/src/__tests__/locale.spec.ts b/core-ts/src/__tests__/locale.spec.ts new file mode 100644 index 00000000..dbdeadb4 --- /dev/null +++ b/core-ts/src/__tests__/locale.spec.ts @@ -0,0 +1,73 @@ +import { getLocale } from '@/locale' +import { CronType, TextPosition } from '@/types' +import { describe, expect, it } from 'vitest' + +describe('locale', () => { + it('getLocale', () => { + const testCases = [ + { locale: 'en', expected: 'Hour' }, + { locale: 'foo-bar', expected: 'Hour' }, + { locale: 'de', expected: 'Stunde' }, + { locale: 'DE-AT', expected: 'Stunde' }, + { locale: 'de-li', expected: 'Stunde' } + ] + + for (const test of testCases) { + const l = getLocale(test.locale) + expect(l.getLocaleStr('hour', 'text')).toBe(test.expected) + } + }) + + it('getLocaleStr', () => { + const l = getLocale('en', { + custom: { + '*': 'bar', + message: 'baz' + } + }) + + expect(l.getLocaleStr('year', 'minute', 'empty', 'text')).toBe('every {{field.id}}') + expect(l.getLocaleStr('year', 'dayOfWeek', 'value', 'prefix')).toBe('and') + expect(l.getLocaleStr('year', 'minute', 'range', 'prefix')).toBe(':') + expect(l.getLocaleStr('custom', 'foo')).toBe('bar') + expect(l.getLocaleStr('custom', 'message')).toBe('baz') + }) + + it('getLocaleStr pt', () => { + const l = getLocale('pt', { + custom: { + '*': 'bar', + message: 'baz' + } + }) + + expect(l.getLocaleStr('year', 'minute', 'empty', 'text')).toBe('cada minuto') + expect(l.getLocaleStr('year', 'dayOfWeek', 'value', 'prefix')).toBe('e de') + expect(l.getLocaleStr('year', 'minute', 'range', 'prefix')).toBe(':') + expect(l.getLocaleStr('custom', 'foo')).toBe('bar') + expect(l.getLocaleStr('custom', 'message')).toBe('baz') + }) + + it('render', () => { + const l = getLocale('en', { + '*': { + '*': { + value: { + text: '{{start.text}}-{{end.text}}' + } + } + } + }) + + expect( + l.render('period', 'field', CronType.Value, TextPosition.Text, { + start: { + text: 'foo' + }, + end: { + text: 'bar' + } + }) + ).toBe('foo-bar') + }) +}) diff --git a/core-ts/src/__tests__/util.spec.ts b/core-ts/src/__tests__/util.spec.ts new file mode 100644 index 00000000..3ac60758 --- /dev/null +++ b/core-ts/src/__tests__/util.spec.ts @@ -0,0 +1,17 @@ +import { Range, deepMerge } from '@/util' +import { describe, expect, it } from 'vitest' + +describe('util', () => { + it('Range', () => { + const r = new Range(0, 10, 2) + expect(r[0]).toBe(0) + expect(r[5]).toBe(10) + expect(Array.from(r)).toEqual([0, 2, 4, 6, 8, 10]) + }) + + it('deepMerge', () => { + expect(deepMerge({ a: { a: 1 } }, { a: { b: 1 }, b: 1 })).toEqual({ a: { a: 1, b: 1 }, b: 1 }) + expect(deepMerge({}, { a: { b: 1 } })).toEqual({ a: { b: 1 } }) + expect(deepMerge({ a: { b: 1 } }, { a: { b: 2 } })).toEqual({ a: { b: 2 } }) + }) +}) diff --git a/core-ts/src/components/HelloWorld.vue b/core-ts/src/components/HelloWorld.vue deleted file mode 100644 index e1a721cc..00000000 --- a/core-ts/src/components/HelloWorld.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - - - diff --git a/core-ts/src/components/TheWelcome.vue b/core-ts/src/components/TheWelcome.vue deleted file mode 100644 index 49d8f735..00000000 --- a/core-ts/src/components/TheWelcome.vue +++ /dev/null @@ -1,88 +0,0 @@ - - - diff --git a/core-ts/src/components/WelcomeItem.vue b/core-ts/src/components/WelcomeItem.vue deleted file mode 100644 index 6d7086ae..00000000 --- a/core-ts/src/components/WelcomeItem.vue +++ /dev/null @@ -1,87 +0,0 @@ - - - diff --git a/core-ts/src/components/__tests__/HelloWorld.spec.ts b/core-ts/src/components/__tests__/HelloWorld.spec.ts deleted file mode 100644 index 25332020..00000000 --- a/core-ts/src/components/__tests__/HelloWorld.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, it, expect } from 'vitest' - -import { mount } from '@vue/test-utils' -import HelloWorld from '../HelloWorld.vue' - -describe('HelloWorld', () => { - it('renders properly', () => { - const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } }) - expect(wrapper.text()).toContain('Hello Vitest') - }) -}) diff --git a/core-ts/src/components/__tests__/cron-segment.spec.ts b/core-ts/src/components/__tests__/cron-segment.spec.ts new file mode 100644 index 00000000..4e5dd630 --- /dev/null +++ b/core-ts/src/components/__tests__/cron-segment.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' + +import { getLocale } from '@/locale' +import { FieldWrapper } from '@/types' +import { defaultItems } from '@/util' +import { nextTick, ref } from 'vue' +import { useCronSegment } from '../cron-segment' + +const f = () => { + const fieldDesc = new FieldWrapper({ + id: 'month', + items: defaultItems('en').monthItems + }) + + const period = { id: 'year', value: [] } + + return useCronSegment({ + locale: getLocale('en'), + field: fieldDesc, + period: ref(period), + initialCron: '*' + }) +} + +describe('CronSegment', () => { + it('cron/array converted properly', async () => { + const { cron, selected, text, select, error } = f() + + const tests = [ + { + cron: '1', + expectedCron: '1', + expectedArr: [1], + expectedText: 'Jan', + error: '' + }, + { + cron: '2-5,6', + expectedCron: '2-6', + expectedArr: [2, 3, 4, 5, 6], + expectedText: 'Feb-Jun', + error: '' + } + ] + + for (const t of tests) { + cron.value = t.cron + await nextTick() + + expect(error.value).toEqual(t.error) + expect(selected.value).toEqual(t.expectedArr) + expect(cron.value).toEqual(t.expectedCron) + expect(text.value).toEqual(t.expectedText) + } + + for (const t of tests) { + select(t.expectedArr) + await nextTick() + + expect(error.value).toEqual(t.error) + expect(selected.value).toEqual(t.expectedArr) + expect(cron.value).toEqual(t.expectedCron) + expect(text.value).toEqual(t.expectedText) + } + }) +}) diff --git a/core-ts/src/components/cron-core.ts b/core-ts/src/components/cron-core.ts new file mode 100644 index 00000000..d00d4713 --- /dev/null +++ b/core-ts/src/components/cron-core.ts @@ -0,0 +1,240 @@ +import type { Localization } from '@/locale/types' +import { computed, defineComponent, ref, watch, type PropType } from 'vue' +import { getLocale } from '../locale' +import { FieldWrapper, TextPosition, type Field, type Period } from '../types' +import { defaultItems } from '../util' +import { useCronSegment } from './cron-segment' + +interface CronOptions { + initialValue?: string + locale?: string + fields?: Field[] + periods?: Period[] + customLocale?: Localization +} + +function createCron(len: number, seg: string = '*') { + return new Array(len).fill(seg).join(' ') +} + +function isDefined(obj: T | undefined): obj is T { + return obj !== undefined +} + +class DefaultCronOptions { + locale = 'en' + + initialValue(len: number, seg: string = '*') { + return createCron(len, seg) + } + + fields(locale: string) { + const items = defaultItems(locale) + + return [ + { id: 'minute', items: items.minuteItems }, + { id: 'hour', items: items.hourItems }, + { id: 'day', items: items.dayItems }, + { id: 'month', items: items.monthItems }, + { id: 'dayOfWeek', items: items.dayOfWeekItems } + ] + } + + periods() { + return [ + { id: 'minute', value: [] }, + { id: 'hour', value: ['minute'] }, + { id: 'day', value: ['hour', 'minute'] }, + { id: 'week', value: ['dayOfWeek', 'hour', 'minute'] }, + { id: 'month', value: ['day', 'dayOfWeek', 'hour', 'minute'] }, + { id: 'year', value: ['month', 'day', 'dayOfWeek', 'hour', 'minute'] } + ] + } +} + +export function useCron(options: CronOptions) { + const cronDefaults = new DefaultCronOptions() + + const locale = options.locale ?? 'en' + const { + customLocale, + fields = cronDefaults.fields(locale), + periods = cronDefaults.periods() + } = options + const initialValue = options.initialValue ?? cronDefaults.initialValue(fields.length) + + const l10n = getLocale(locale, customLocale) + + const cron = ref(initialValue) + const error = ref('') + const period = ref(periods[periods.length - 1]) + const periodText = ref('') + const periodPrefix = ref('') + const periodSuffix = ref('') + const segments = fields.map((f) => { + return useCronSegment({ field: new FieldWrapper(f), locale: l10n, period }) + }) + + const segmentMap = new Map(segments.map((s) => [s.field.id, s])) + const selected = computed(() => { + return period.value.value.map((fieldId) => { + const segment = segmentMap.get(fieldId) + if (isDefined(segment)) { + return segment + } + throw Error('${fieldId} not found') + }) + }) + + const fromCron = (value: string) => { + if (!value) { + cron.value = createCron(fields.length) + return + } + + const strSegments = value.split(' ') + + if (strSegments.length !== fields.length) { + error.value = 'invalid pattern' + return + } + + for (let i = 0; i < strSegments.length; i++) { + if (segments[i].cron.value != strSegments[i]) { + segments[i].cron.value = strSegments[i] + } + } + error.value = '' + } + fromCron(initialValue) + + const toCron = () => { + cron.value = segments + .map((s) => { + return period.value.value.includes(s.field.id) ? s.cron.value : '*' + }) + .join(' ') + } + + const translate = () => { + periodPrefix.value = l10n.getLocaleStr(period.value.id, TextPosition.Prefix) + periodSuffix.value = l10n.getLocaleStr(period.value.id, TextPosition.Suffix) + } + translate() + + watch(cron, fromCron) + watch(period, translate) + + segments.forEach((s) => { + watch(s.cron, toCron) + }) + + return { + cron, + error, + segments, + selected, + period: { + select: (periodId: string) => { + const i = periods.map((p) => p.id).indexOf(periodId) + if (i == -1) { + return + } + period.value = periods[i] + }, + selected: period, + items: periods.map((p) => { + return { + ...p, + text: l10n.getLocaleStr(p.id, TextPosition.Text) + } + }), + text: periodText, + prefix: periodPrefix, + suffix: periodSuffix + }, + fields + } +} + +export const cronProps = { + modelValue: { + type: String + }, + locale: { + type: String + }, + fields: { + type: Array as PropType + }, + periods: { + type: Array as PropType + }, + customLocale: { + type: Object as PropType + } +} + +export function useCronComponent() { + return defineComponent({ + name: 'VueCronCore', + props: cronProps, + emits: ['update:model-value', 'error'], + setup(props, ctx) { + const { cron, error, selected, period } = useCron(props) + + watch(cron, (value) => { + ctx.emit('update:model-value', value) + }) + + watch(error, (value) => { + ctx.emit('error', value) + }) + + watch( + () => props.modelValue, + (value) => { + if (value) { + cron.value = value + } + } + ) + + return () => { + const slotProps = { + error: error, + fields: selected.value.map((s) => { + return { + id: s.field.id, + items: s.field.items, + cron: s.cron.value, + selectedStr: s.text.value, + events: { + 'update:model-value': s.select + }, + attrs: { + modelValue: s.selected.value + }, + prefix: s.prefix.value, + suffix: s.suffix.value + } + }), + + period: { + attrs: { + modelValue: period.selected.value.id + }, + events: { + 'update:model-value': period.select + }, + items: period.items, + prefix: period.prefix.value, + suffix: period.suffix.value + } + } + + return ctx.slots.default?.(slotProps) + } + } + }) +} diff --git a/core-ts/src/components/cron-segment.ts b/core-ts/src/components/cron-segment.ts new file mode 100644 index 00000000..d8eaa289 --- /dev/null +++ b/core-ts/src/components/cron-segment.ts @@ -0,0 +1,94 @@ +import { CombinedSegment, arrayToSegment, cronToSegment, type CronSegment } from '@/cron' +import type { Locale } from '@/locale' +import { TextPosition, type FieldWrapper, type Period } from '@/types' +import { ref, watch, type Ref } from 'vue' + +export interface FieldOptions { + locale: Locale + period: Ref + field: FieldWrapper + initialCron?: string +} + +export function useCronSegment(options: FieldOptions) { + const { period, field, initialCron = '*', locale } = options + + const cron = ref(initialCron) + const error = ref('') + const selected = ref([]) + + const text = ref('') + const prefix = ref('') + const suffix = ref('') + + const translate = (seg: CronSegment) => { + const segments = seg instanceof CombinedSegment ? seg.segments : [seg] + + text.value = segments + .map((seg) => { + return locale.render(period.value.id, field.id, seg.type, TextPosition.Text, { + field: field, + ...seg.items + }) + }) + .join(',') + + prefix.value = locale.getLocaleStr(period.value.id, field.id, seg.type, TextPosition.Prefix) + suffix.value = locale.getLocaleStr(period.value.id, field.id, seg.type, TextPosition.Suffix) + } + + const parseCron = (cron: string) => { + const seg = cronToSegment(cron, field) + if (seg != null) { + selected.value = seg.toArray() + translate(seg) + } else { + error.value = `${cron} is not a valid cron segment (${field.id})` + } + } + + const toCron = (value: number[]) => { + const seg = arrayToSegment(value, field) + if (seg != null) { + cron.value = seg.toCron() + translate(seg) + } else { + error.value = `failed to convert ${value} to cron (${field.id})` + } + } + + parseCron(initialCron) + + const select = (evt: number[]) => { + const sorted = Array.from(evt).sort((a, b) => { + return a > b ? 1 : -1 + }) + selected.value = sorted + } + + watch(cron, (value) => { + parseCron(value) + }) + + watch(selected, (value) => { + toCron(value) + }) + + watch(period, () => { + const seg = cronToSegment(cron.value, field) + if (seg != null) { + translate(seg) + } + }) + + return { + field, + cron, + selected, + error, + select, + text, + prefix, + suffix + } +} diff --git a/core-ts/src/components/select.ts b/core-ts/src/components/select.ts new file mode 100644 index 00000000..c9fb633f --- /dev/null +++ b/core-ts/src/components/select.ts @@ -0,0 +1,72 @@ +import { defineComponent, ref, type PropType } from 'vue' + +interface SelectOptions { + initialValue?: T[] + items: T[] +} + +export function useSelect(options: SelectOptions) { + const { initialValue = [], items } = options + + const selected = ref(new Set()) + + const add = (item: T) => { + if (items.indexOf(item) == -1) { + return + } + selected.value.add(item) + } + initialValue.forEach((i) => add(i)) + + const has = (item: T) => { + selected.value.has(item) + } + + const remove = (item: T) => { + selected.value.delete(item) + } + + const clear = () => { + selected.value.clear() + } + + const set = (item: T) => { + clear(); + add(item); + } + + return { + selected, + add, + set, + has, + remove, + clear + } +} + +export function useSelectComponent() { + const selectProps = { + initialValue: { + type: Array as PropType>, + default: () => [] + }, + items: { + type: Array as PropType>, + default: () => [] + }, + } + + return defineComponent({ + props: selectProps, + setup(props, ctx) { + const select = useSelect(props) + + return () => { + return ctx.slots.default?.(select) + } + } + }) +} + + diff --git a/core-ts/src/cron.ts b/core-ts/src/cron.ts new file mode 100644 index 00000000..2dcfc151 --- /dev/null +++ b/core-ts/src/cron.ts @@ -0,0 +1,365 @@ +import { CronType, FieldWrapper, type FieldItem } from './types' +import { isSquence, range, unimplemented } from './util' + +type SegmentFromArray = (arr: number[], field: FieldWrapper) => CronSegment | null +type SegmentFromString = (str: string, field: FieldWrapper) => CronSegment | null + +export interface CronSegment { + field: FieldWrapper + type: CronType + toCron: () => string + toArray: () => number[] + items: Record +} + +class AnySegment implements CronSegment { + field: FieldWrapper + type: CronType = CronType.Empty + + constructor(field: FieldWrapper) { + this.field = field + } + + toCron() { + return '*' + } + + toArray() { + return [] + } + + get items() { + return {} + } + + static fromString(str: string, field: FieldWrapper) { + if (str !== '*') { + return null + } + return new AnySegment(field) + } + + static fromArray(arr: number[], field: FieldWrapper) { + const { items } = field + + if (arr.length === 0) { + return new AnySegment(field) + } + if (arr.length !== items.length) { + return null + } + + for (const item of items) { + if (!arr.includes(item.value)) { + return null + } + } + if (!isSquence(items.map((item) => item.value))) { + return null + } + return new AnySegment(field) + } +} + +class RangeSegment implements CronSegment { + static re = /^\d+-\d+$/ + + field: FieldWrapper + type: CronType = CronType.Range + start: number + end: number + + constructor(field: FieldWrapper, start: number, end: number) { + this.field = field + this.start = start + this.end = end + } + + toCron() { + return `${this.start}-${this.end}` + } + + toArray() { + const start = this.start + const end = this.end + + return range(start, end) + } + + get items() { + return { + start: this.field.itemMap[this.start], + end: this.field.itemMap[this.end] + } + } + + static fromString(str: string, field: FieldWrapper) { + if (!RangeSegment.re.test(str)) { + return null + } + + const { min, max } = field + const range = str.split('-') + const start = parseInt(range[0]) + const end = parseInt(range[1]) + + if (start > end || start < min || end > max) { + return null + } + + return new RangeSegment(field, start, end) + } +} + +const _every = (n: number, min: number, max: number) => { + const start = n * Math.floor(min / n) + const res = [] + for (let i = start; i <= max; i += n) { + if (i >= min) { + res.push(i) + } + } + return res +} + +class EverySegment implements CronSegment { + static re = /^\*\/\d+$/ + + field: FieldWrapper + type: CronType = CronType.EveryX + every: number + + constructor(field: FieldWrapper, every: number) { + this.field = field + this.every = every + } + + toCron() { + return `*/${this.every}` + } + + toArray() { + const { min, max } = this.field + return _every(this.every, min, max) + } + + get items() { + return { + every: this.field.itemMap[this.every] + } + } + + static fromString(str: string, field: FieldWrapper) { + if (!EverySegment.re.test(str)) { + return null + } + + const [, everyStr] = str.split('/') + const every = parseInt(everyStr) + const { min, max } = field + + if (_every(every, min, max).length == 0) { + return null + } + + return new EverySegment(field, every) + } + + static fromArray(arr: number[], field: FieldWrapper) { + const { min, max } = field + + if (arr.length < 3) { + return null + } + + const step = arr[1] - arr[0] + if (step <= 1) { + return null + } + + const first = min % step === 0 ? min : (Math.floor(min / step) + 1) * step + if (arr.length !== Math.floor((max - first) / step) + 1) { + return null + } + + for (const value of arr) { + if (value % step !== 0) { + return null + } + } + + return new EverySegment(field, step) + } +} + +class ValueSegment implements CronSegment { + field: FieldWrapper + type: CronType = CronType.Value + value: number + + constructor(field: FieldWrapper, value: number) { + this.field = field + this.value = value + } + + toCron() { + return `${this.value}` + } + + toArray() { + return [this.value] + } + + get items() { + return { + value: this.field.itemMap[this.value] + } + } + + static fromString(str: string, field: FieldWrapper) { + const { min, max } = field + const value = parseInt(str) + return String(value) === str && value >= min && value <= max + ? new ValueSegment(field, value) + : null + } + + static fromArray(arr: number[], field: FieldWrapper) { + const { min, max } = field + + if (arr.length != 1) { + return null + } + + const value = arr[0] + if (value < min || value > max) { + return null + } + + return value + } +} + +class CombinedSegment implements CronSegment { + static segmentFactories: SegmentFromString[] = [ + AnySegment.fromString, + EverySegment.fromString, + RangeSegment.fromString, + ValueSegment.fromString + ] + + field: FieldWrapper + segments: CronSegment[] + + constructor(field: FieldWrapper, segments: CronSegment[] = []) { + this.field = field + this.segments = segments + } + + get type() { + if (this.segments.length == 1) { + return this.segments[0].type + } + return CronType.Range + } + + addSegment(segment: CronSegment) { + this.segments.push(segment) + } + + toCron() { + return this.segments.map((c) => c.toCron()).join(',') + } + + toArray() { + const values = new Set() + for (const seg of this.segments) { + seg.toArray().forEach((value) => values.add(value)) + } + return Array.from(values) + } + + get items() { + return unimplemented() + } + + static fromString(str: string, field: FieldWrapper) { + let segments: CronSegment[] = [] + for (const strSeg of str.split(',')) { + if (strSeg === '*') { + segments = [new AnySegment(field)] + break + } + + let segment = null + for (const fromString of CombinedSegment.segmentFactories) { + segment = fromString(strSeg, field) + if (segment !== null) { + break + } + } + if (segment === null) { + return null + } + segments.push(segment) + } + return new CombinedSegment(field, segments) + } + + static fromArray(arr: number[], field: FieldWrapper) { + const { min, max } = field + + const minValue = arr[0] + const maxValue = arr[arr.length - 1] + + if (minValue < min) { + return null + } + if (maxValue > max) { + return null + } + + const ranges: CronSegment[] = [] + let start = 0 + for (let i = 0; i < arr.length; i++) { + if (arr[i + 1] === undefined || arr[i + 1] - arr[i] > 1) { + if (i === start) { + ranges.push(new ValueSegment(field, arr[start])) + } else { + ranges.push(new RangeSegment(field, arr[start], arr[i])) + } + start = i + 1 + } + } + + return new CombinedSegment(field, ranges) + } +} + +function cronToSegment(cron: string, field: FieldWrapper) { + return CombinedSegment.fromString(cron, field) +} + +function arrayToSegment(arr: number[], field: FieldWrapper) { + for (const fromArray of [ + AnySegment.fromArray, + EverySegment.fromArray, + CombinedSegment.fromArray + ]) { + const seg = fromArray(arr, field) + if (seg != null) { + return seg + } + } + return null +} + +export { + AnySegment, + CombinedSegment, + EverySegment, + RangeSegment, + ValueSegment, + arrayToSegment, + cronToSegment +} diff --git a/core-ts/src/index.ts b/core-ts/src/index.ts new file mode 100644 index 00000000..c9f674af --- /dev/null +++ b/core-ts/src/index.ts @@ -0,0 +1,15 @@ +import { type App } from 'vue' +import { useCronComponent } from './components/cron-core' + +export { useCron, useCronComponent } from './components/cron-core' +export { useSelect, useSelectComponent } from './components/select' +export { Locale, getLocale } from './locale' +export { defaultItems, genItems, pad } from './util' + +function install(app: App) { + app.component('CronCore', useCronComponent()) +} + +export const corePlugin = { + install, +} diff --git a/core-ts/src/locale/cn.ts b/core-ts/src/locale/cn.ts new file mode 100644 index 00000000..0c6e8c9f --- /dev/null +++ b/core-ts/src/locale/cn.ts @@ -0,0 +1,83 @@ +import type { Localization } from './types' + +const locale: Localization = { + '*': { + prefix: '每', + suffix: '', + text: '未知', + '*': { + empty: { text: '每 {{field.id}}' }, + value: { text: '{{value.text}}' }, + range: { text: '{{start.text}}-{{end.text}}' }, + everyX: { text: '每 {{every.value}}' } + }, + month: { + '*': { prefix: '的' }, + empty: { text: '每月' }, + value: { text: '{{value.alt}}' }, + range: { text: '{{start.alt}}-{{end.alt}}' } + }, + day: { + '*': { prefix: '的' }, + empty: { text: '每日' }, + value: { text: '{{value.alt}}号' }, + range: { text: '{{start.alt}}号-{{end.alt}}号' } + }, + dayOfWeek: { + '*': { prefix: '的' }, + empty: { text: '一周的每一天' }, + value: { text: '{{value.alt}}' }, + range: { text: '{{start.alt}}-{{end.alt}}' } + }, + hour: { + '*': { prefix: '的' }, + empty: { text: '每小时' } + }, + minute: { + '*': { prefix: ':' }, + empty: { text: '每分钟' } + } + }, + minute: { + text: '分' + }, + hour: { + text: '小时', + minute: { + '*': { + prefix: ':', + suffix: '分钟' + }, + empty: { text: '每' } + } + }, + day: { + text: '天' + }, + week: { + text: '周', + dayOfWeek: { + '*': { prefix: '的' }, + empty: { text: '每天' }, + value: { text: '{{value.alt}}' }, + range: { text: '{{start.alt}}-{{end.alt}}' } + } + }, + month: { + text: '月', + dayOfWeek: { + '*': { prefix: '和' } + }, + day: { + '*': { prefix: '的' } + } + }, + year: { + text: '年', + dayOfWeek: { + '*': { prefix: '和' } + } + } +} + +export default locale diff --git a/core-ts/src/locale/da.ts b/core-ts/src/locale/da.ts new file mode 100644 index 00000000..5e548b17 --- /dev/null +++ b/core-ts/src/locale/da.ts @@ -0,0 +1,68 @@ +import type { Localization } from './types' + +const locale: Localization = { + '*': { + prefix: 'Hver', + suffix: '', + text: 'Ukendt', + '*': { + empty: { text: 'hver {{field.id}}' }, + value: { text: '{{value.text}}' }, + range: { text: '{{start.text}}-{{end.text}}' }, + everyX: { text: 'hver {{every.value}}' } + }, + month: { + '*': { prefix: 'i' }, + value: { text: '{{value.alt}}' }, + range: { text: '{{start.alt}}-{{end.alt}}' } + }, + day: { + '*': { prefix: 'på' } + }, + dayOfWeek: { + '*': { prefix: 'på' }, + empty: { text: 'hver dag i ugen' }, + value: { text: '{{value.alt}}' }, + range: { text: '{{start.alt}}-{{end.alt}}' } + }, + hour: { + '*': { prefix: 'klokken' } + }, + minute: { + '*': { prefix: ':' } + } + }, + minute: { + text: 'Minut' + }, + hour: { + text: 'Time', + minute: { + '*': { + prefix: 'på de(t)', + suffix: 'minutter' + }, + empty: { text: 'hver' } + } + }, + day: { + text: 'Dag' + }, + week: { + text: 'Uge' + }, + month: { + text: 'Måned', + dayOfWeek: { + '*': { prefix: 'og' } + } + }, + year: { + text: 'År', + dayOfWeek: { + '*': { prefix: 'og' } + } + } +} + +export default locale diff --git a/core-ts/src/locale/de.ts b/core-ts/src/locale/de.ts new file mode 100644 index 00000000..5315e5a0 --- /dev/null +++ b/core-ts/src/locale/de.ts @@ -0,0 +1,92 @@ +import type { Localization } from './types' + +const locale: Localization = { + '*': { + prefix: 'Jede', + suffix: '', + text: 'Unknown', + '*': { + value: { text: '{{value.text}}' }, + range: { text: '{{start.text}}-{{end.text}}' }, + everyX: { text: 'alle {{every.value}}' } + }, + month: { + '*': { prefix: 'im' }, + empty: { + prefix: 'in', + text: 'jedem Monat' + }, + value: { text: '{{value.alt}}' }, + range: { text: '{{start.alt}}-{{end.alt}}' } + }, + day: { + '*': { prefix: 'den' }, + empty: { + prefix: 'an', + text: 'jedem Tag' + }, + everyX: { + prefix: '', + text: 'alle {{every.value}} Tage' + } + }, + dayOfWeek: { + '*': { prefix: 'am' }, + empty: { + prefix: 'an', + text: 'jedem Wochentag' + }, + value: { text: '{{value.alt}}' }, + range: { text: '{{start.alt}}-{{end.alt}}' } + }, + hour: { + '*': { prefix: 'um' }, + empty: { + prefix: 'zu', + text: 'jeder Stunde' + }, + everyX: { + prefix: '', + text: 'alle {{every.value}} Stunden' + } + }, + minute: { + '*': { prefix: ':' }, + empty: { text: 'jede Minute' }, + everyX: { + prefix: '', + text: 'alle {{every.value}} Minuten' + } + } + }, + minute: { + text: 'Minute' + }, + hour: { + text: 'Stunde', + minute: { + '*': { + prefix: 'zu', + suffix: 'Minute(n)' + }, + empty: { text: 'jeder' } + } + }, + day: { + prefix: 'Jeden', + text: 'Tag' + }, + week: { + text: 'Woche' + }, + month: { + prefix: 'Jedes', + text: 'Monat' + }, + year: { + prefix: 'Jedes', + text: 'Jahr' + } +} + +export default locale diff --git a/core-ts/src/locale/en.ts b/core-ts/src/locale/en.ts new file mode 100644 index 00000000..127f76df --- /dev/null +++ b/core-ts/src/locale/en.ts @@ -0,0 +1,68 @@ +import type { Localization } from './types' + +const locale: Localization = { + '*': { + prefix: 'Every', + suffix: '', + text: 'Unknown', + '*': { + empty: { text: 'every {{field.id}}' }, + value: { text: '{{value.text}}' }, + range: { text: '{{start.text}}-{{end.text}}' }, + everyX: { text: 'every {{every.value}}' } + }, + month: { + '*': { prefix: 'in' }, + value: { text: '{{value.alt}}' }, + range: { text: '{{start.alt}}-{{end.alt}}' } + }, + day: { + '*': { prefix: 'on' } + }, + dayOfWeek: { + '*': { prefix: 'on' }, + empty: { text: 'every day of the week' }, + value: { text: '{{value.alt}}' }, + range: { text: '{{start.alt}}-{{end.alt}}' } + }, + hour: { + '*': { prefix: 'at' } + }, + minute: { + '*': { prefix: ':' } + } + }, + minute: { + text: 'Minute' + }, + hour: { + text: 'Hour', + minute: { + '*': { + prefix: 'at', + suffix: 'minute(s)' + }, + empty: { text: 'every' } + } + }, + day: { + text: 'Day' + }, + week: { + text: 'Week' + }, + month: { + text: 'Month', + dayOfWeek: { + '*': { prefix: 'and' } + } + }, + year: { + text: 'Year', + dayOfWeek: { + '*': { prefix: 'and' } + } + } +} + +export default locale diff --git a/core-ts/src/locale/es.ts b/core-ts/src/locale/es.ts new file mode 100644 index 00000000..00420c4f --- /dev/null +++ b/core-ts/src/locale/es.ts @@ -0,0 +1,76 @@ +import type { Localization } from './types' + +const locale: Localization = { + '*': { + prefix: 'todos los', + suffix: '', + text: 'Desconocido', + '*': { + empty: { text: 'todos los {{ field.id }}' }, + value: { text: '{{ value.text }}' }, + range: { text: '{{ start.text }}-{{ end.text }}' }, + everyX: { text: 'todos/as {{ every.value }}' } + }, + month: { + '*': { prefix: 'en' }, + empty: { text: 'todos los meses' }, + value: { text: '{{ value.alt }}' }, + range: { text: '{{ start.alt }}-{{ end.alt }}' } + }, + day: { + '*': { prefix: 'en' }, + empty: { text: 'todos los días' }, + value: { text: 'los días {{ value.alt }}' } + }, + dayOfWeek: { + '*': { prefix: 'de' }, + empty: { text: 'todos los días de la semana' }, + value: { text: 'los {{ value.alt }}' }, + range: { text: '{{ start.alt }}-{{ end.alt }}' } + }, + hour: { + '*': { prefix: 'a' }, + empty: { text: 'todas las horas' }, + value: { text: 'las {{ value.text }}' } + }, + minute: { + '*': { prefix: ':' }, + empty: { text: 'todos los minutos' } + } + }, + minute: { + prefix: 'todos los', + text: 'minutos' + }, + hour: { + prefix: 'todas las', + text: 'horas', + minute: { + '*': { + prefix: 'a los', + suffix: 'minutos' + }, + empty: { text: 'todos', prefix: 'a', suffix: 'los minutos' } + } + }, + day: { + text: 'Días' + }, + week: { + text: 'Semanas' + }, + month: { + text: 'Meses', + dayOfWeek: { + '*': { prefix: 'y' } + } + }, + year: { + text: 'años', + dayOfWeek: { + '*': { prefix: 'y' } + } + } +} + +export default locale diff --git a/core-ts/src/locale/index.ts b/core-ts/src/locale/index.ts new file mode 100644 index 00000000..388af945 --- /dev/null +++ b/core-ts/src/locale/index.ts @@ -0,0 +1,60 @@ +import type { CronType, TextPosition } from '@/types' +import Mustache from 'mustache' +import { deepMerge, traverse } from '../util' +import cn from './cn' +import da from './da' +import de from './de' +import en from './en' +import es from './es' +import pt from './pt' +import type { Localization } from './types' + +const locales: Record = { + empty: {}, + en, + de, + pt, + es, + da, + zh: cn, + 'zh-cn': cn +} + +class Locale { + dict: Localization + + constructor(dict: Localization) { + this.dict = dict + } + + getLocaleStr(...keys: string[]) { + const k = keys.map((key) => [key, '*']) + return traverse(this.dict, ...k) || '' + } + + render( + periodId: string, + fieldId: string, + cronType: CronType, + position: TextPosition, + params: any + ) { + const template = this.getLocaleStr(periodId, fieldId, cronType, position) + return Mustache.render(template, params || {}) + } +} + +/** + * + * @param locale - locale code, e.g.: en, en-GB de-DE + * @param mixin - can be used to override values of the Locale + * @returns {Locale} Dictionary with all strings in the requested language + */ +function getLocale(localeCode: string, mixin?: Localization) { + const [language] = localeCode.split('-') + const l = locales[localeCode.toLowerCase()] || locales[language.toLowerCase()] || locales.en + const dict = deepMerge(l, mixin || {}) as Localization + return new Locale(dict) +} + +export { Locale, getLocale } diff --git a/core-ts/src/locale/pt.ts b/core-ts/src/locale/pt.ts new file mode 100644 index 00000000..fc885ac3 --- /dev/null +++ b/core-ts/src/locale/pt.ts @@ -0,0 +1,72 @@ +import type { Localization } from './types' + +const locale: Localization = { + '*': { + prefix: 'Todo(a)', + suffix: '', + text: 'Desconhecido', + '*': { + empty: { text: 'todo {{field.id}}' }, + value: { text: '{{value.text}}' }, + range: { text: '{{start.text}}-{{end.text}}' }, + everyX: { text: 'todo {{every.value}}' } + }, + month: { + '*': { prefix: 'de' }, + value: { text: '{{value.alt}}' }, + range: { text: '{{start.alt}}-{{end.alt}}' }, + empty: { text: 'todo mês' } + }, + day: { + '*': { prefix: 'no(s) dia(s)' }, + empty: { text: 'todos' } + }, + dayOfWeek: { + '*': { prefix: 'de' }, + empty: { text: 'todos dias da semana' }, + value: { text: '{{value.alt}}' }, + range: { text: '{{start.alt}}-{{end.alt}}' } + }, + hour: { + '*': { prefix: 'às' }, + empty: { text: 'cada hora' } + }, + minute: { + '*': { prefix: ':' }, + empty: { text: 'cada minuto' } + } + }, + minute: { + text: 'Minuto' + }, + hour: { + text: 'Hora', + minute: { + '*': { + prefix: 'e', + suffix: 'minuto(s)' + }, + empty: { text: 'cada' } + } + }, + day: { + text: 'Dia' + }, + week: { + text: 'Semana' + }, + month: { + text: 'Mês', + dayOfWeek: { + '*': { prefix: 'e de' } + } + }, + year: { + text: 'Ano', + dayOfWeek: { + '*': { prefix: 'e de' } + } + } +} + +export default locale diff --git a/core-ts/src/locale/types.ts b/core-ts/src/locale/types.ts new file mode 100644 index 00000000..3ddb34e2 --- /dev/null +++ b/core-ts/src/locale/types.ts @@ -0,0 +1,34 @@ +import type { CronType, TextPosition } from '@/types' + +// https://stackoverflow.com/a/53276873/3140799 +type PartialRecord = Partial> + +type PositionedLocalization = PartialRecord +type CronLocalization = PartialRecord + +interface FieldLocalization { + // Allow custom field ids + [fieldId: string]: unknown + + '*'?: CronLocalization + minute?: CronLocalization + hour?: CronLocalization + day?: CronLocalization + month?: CronLocalization + dayOfWeek?: CronLocalization +} + +type PeriodLocalization = PositionedLocalization | FieldLocalization + +export interface Localization { + // Allow custom period ids + [periodId: string]: unknown + + '*'?: PeriodLocalization + minute?: PeriodLocalization + hour?: PeriodLocalization + day?: PeriodLocalization + week?: PeriodLocalization + month?: PeriodLocalization + year?: PeriodLocalization +} diff --git a/core-ts/src/types.ts b/core-ts/src/types.ts new file mode 100644 index 00000000..71119e79 --- /dev/null +++ b/core-ts/src/types.ts @@ -0,0 +1,65 @@ +export enum CronType { + Empty = 'empty', + Value = 'value', + Range = 'range', + EveryX = 'everyX', + Combined = 'combined' +} + +export enum TextPosition { + Prefix = 'prefix', + Suffix = 'suffix', + Text = 'text' +} + +export interface FieldItem { + value: number + text: string + alt: string +} + +export interface Field { + id: string + items: FieldItem[] +} + +export interface Period { + id: string + value: string[] +} + +export class FieldWrapper { + field: Field + itemMap: Record + + constructor(field: Field) { + this.field = field + + this.itemMap = this.field.items.reduce( + (acc, item) => { + acc[item.value] = item + return acc + }, + {} as Record + ) + } + + get id() { + return this.field.id + } + get items() { + return this.field.items + } + + get min() { + return this.items[0].value + } + + get max() { + return this.items[this.items.length - 1].value + } + + getItem(value: number) { + return this.itemMap[value] + } +} diff --git a/core-ts/src/util.ts b/core-ts/src/util.ts new file mode 100644 index 00000000..39c51a1f --- /dev/null +++ b/core-ts/src/util.ts @@ -0,0 +1,190 @@ +import type { FieldItem } from './types' + +function range(start: number, end: number, step = 1) { + const r = [] + for (let i = start; i <= end; i += step) { + r.push(i) + } + return r +} + +class Range { + [name: string]: number + start: number + end: number + step: number + + constructor(start: number, end: number, step = 1) { + this.start = start + this.end = end + this.step = step + + return new Proxy(this, { + get: function (target, prop) { + const i = typeof prop === 'string' ? parseInt(prop) : prop + if (typeof i === 'number' && i >= 0 && i <= target.length) { + return target.start + target.step * i + } + return Reflect.get(target, prop) + } + }) + } + + get length() { + return (this.end - this.start) / this.step + 1 + } + + [Symbol.iterator]() { + let index = -1 + return { + next: () => { + return { value: this[++index], done: !(this[index + 1] !== undefined) } + } + } + } +} + +type toText = (value: number) => string + +/** + * generate items for fields + * @param min - first value + * @param max - last value + * @param genText - returns a string representation of value + * @param genAltText - returns an alternative string representation of value + * @returns array of items + */ +function genItems( + min: number, + max: number, + genText: toText = (value) => { + return value + '' + }, + genAltText: toText = (value) => { + return value + '' + } +): FieldItem[] { + const res = [] + for (const i of new Range(min, max)) { + res.push({ + text: genText(i), + alt: genAltText(i), + value: i + }) + } + return res +} + +/** + * + * @param locale - locale code, e.g.: en, en-GB de-DE + * @returns items for minute, hour, day, month and day of week + */ +function defaultItems(localeCode: string) { + return { + minuteItems: genItems(0, 59, (value) => pad(value, 2)), + hourItems: genItems(0, 23, (value) => pad(value, 2)), + dayItems: genItems(1, 31), + monthItems: genItems( + 1, + 12, + (value) => { + return new Date(2021, value - 1, 1).toLocaleDateString(localeCode, { month: 'long' }) + }, + (value) => { + return new Date(2021, value - 1, 1).toLocaleDateString(localeCode, { month: 'short' }) + } + ), + dayOfWeekItems: genItems( + 0, + 6, + (value) => { + const date = new Date(2021, 0, 3 + value) // first sunday in 2021 + return date.toLocaleDateString(localeCode, { weekday: 'long' }) + }, + (value) => { + const date = new Date(2021, 0, 3 + value) // first sunday in 2021 + return date.toLocaleDateString(localeCode, { weekday: 'short' }) + } + ) + } +} + +/** + * pads numbers + * @param n - number to pad + * @param width - length of final string + * @example + * ``` + * //returns "001" + * util.pad(1,3) + * ``` + * @returns the padded number + */ +function pad(n: number, width: number) { + const s = n + '' + return s.length < width ? new Array(width - s.length).fill('0').join('') + n : s +} + +/** + * determines whether the passed value is an object + * @returns true if value is an object, otherwise false + */ +function isObject(value: any): value is { [key: string]: any } { + return value && typeof value === 'object' && !Array.isArray(value) +} + +/** + * copies (deep copy) all properties from each source to target + */ +function deepMerge(target: Object, ...sources: { [key: string]: any }[]) { + if (!isObject(target) || sources.length === 0) return + const source = sources.shift() + + if (isObject(source)) { + for (const [key, value] of Object.entries(source)) { + if (isObject(value)) { + if (!isObject(target[key])) { + target[key] = {} + } + deepMerge(target[key], source[key]) + } else { + target[key] = source[key] + } + } + } + + if (sources.length > 0) deepMerge(target, sources) + return target +} + +function traverse(obj: { [key: string]: any }, ...keys: string[][]): any { + if (keys.length === 0) { + return obj + } + + for (const key of keys[0]) { + if (key in obj) { + const res = traverse(obj[key], ...keys.slice(1)) + if (res !== undefined) { + return res + } + } + } +} + +function isSquence(numbers: number[]) { + for (let i = 1; i < numbers.length; i++) { + if (numbers[i - 1] + 1 !== numbers[i]) { + return false + } + } + return true +} + +function unimplemented(): never { + throw new Error('not implemented') +} + +export { Range, deepMerge, defaultItems, genItems, isObject, isSquence, pad, range, traverse, unimplemented } + diff --git a/core-ts/vite.config.ts b/core-ts/vite.config.ts index 5c45e1d9..dd82f7bd 100644 --- a/core-ts/vite.config.ts +++ b/core-ts/vite.config.ts @@ -1,13 +1,31 @@ import { fileURLToPath, URL } from 'node:url' -import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' +import path from 'node:path' +import { defineConfig } from 'vite' // https://vitejs.dev/config/ export default defineConfig({ plugins: [ vue(), ], + build: { + sourcemap: true, + lib: { + entry: path.resolve(__dirname, "src/index.ts"), + name: "CronCore", + fileName: "core" + }, + rollupOptions: { + external: ["vue"], + output: { + globals: { + vue: "Vue", + + } + } + } + }, resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) diff --git a/core-ts/yarn.lock b/core-ts/yarn.lock index b26ff3be..64eb979b 100644 --- a/core-ts/yarn.lock +++ b/core-ts/yarn.lock @@ -291,6 +291,11 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.13.tgz#02c24f4363176d2d18fc8b70b9f3c54aba178a85" integrity sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ== +"@types/mustache@^4.2.3": + version "4.2.3" + resolved "https://registry.yarnpkg.com/@types/mustache/-/mustache-4.2.3.tgz#11ae9d7cd67c60746e3125baa8e5989db781d704" + integrity sha512-MG+oI3oelPKLN2gpkel08v6Tp6zU2zZQRq+eSpRsFtLNTd2kxZolOHQTmQQs0wqXTLOqs+ri3tRUaagH5u0quw== + "@types/node@*": version "20.8.4" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.4.tgz#0e9ebb2ff29d5c3302fc84477d066fa7c6b441aa" @@ -1763,6 +1768,11 @@ muggle-string@^0.3.1: resolved "https://registry.yarnpkg.com/muggle-string/-/muggle-string-0.3.1.tgz#e524312eb1728c63dd0b2ac49e3282e6ed85963a" integrity sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg== +mustache@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64" + integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ== + nanoid@^3.3.6: version "3.3.6" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"