diff --git a/src/custom-sort/matchers.ts b/src/custom-sort/matchers.ts index 31504a9..a48133e 100644 --- a/src/custom-sort/matchers.ts +++ b/src/custom-sort/matchers.ts @@ -1,3 +1,7 @@ +import { + getDateForWeekOfYear +} from "../utils/week-of-year"; + export const RomanNumberRegexStr: string = ' *([MDCLXVI]+)'; // Roman number export const CompoundRomanNumberDotRegexStr: string = ' *([MDCLXVI]+(?:\\.[MDCLXVI]+)*)';// Compound Roman number with dot as separator export const CompoundRomanNumberDashRegexStr: string = ' *([MDCLXVI]+(?:-[MDCLXVI]+)*)'; // Compound Roman number with dash as separator @@ -6,15 +10,26 @@ export const NumberRegexStr: string = ' *(\\d+)'; // Plain number export const CompoundNumberDotRegexStr: string = ' *(\\d+(?:\\.\\d+)*)'; // Compound number with dot as separator export const CompoundNumberDashRegexStr: string = ' *(\\d+(?:-\\d+)*)'; // Compound number with dash as separator +export const Date_yyyy_mm_dd_RegexStr: string = ' *(\\d{4}-[0-3]*[0-9]-[0-3]*[0-9])' +export const Date_yyyy_dd_mm_RegexStr: string = Date_yyyy_mm_dd_RegexStr + export const Date_dd_Mmm_yyyy_RegexStr: string = ' *([0-3]*[0-9]-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\\d{4})'; // Date like 01-Jan-2020 export const Date_Mmm_dd_yyyy_RegexStr: string = ' *((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-[0-3]*[0-9]-\\d{4})'; // Date like Jan-01-2020 -export const DOT_SEPARATOR = '.' +export const Date_yyyy_Www_mm_dd_RegexStr: string = ' *(\\d{4}-W[0-5]*[0-9] \\([0-3]*[0-9]-[0-3]*[0-9]\\))' +export const Date_yyyy_WwwISO_RegexStr: string = ' *(\\d{4}-W[0-5]*[0-9][-+]?)' +export const Date_yyyy_Www_RegexStr: string = Date_yyyy_WwwISO_RegexStr + +export const DOT_SEPARATOR = '.' // ASCII 46 export const DASH_SEPARATOR = '-' -const SLASH_SEPARATOR = '/' // ASCII 47 +const SLASH_SEPARATOR = '/' // ASCII 47, right before ASCII 48 = '0' +const GT_SEPARATOR = '>' // ASCII 62, alphabetical sorting in Collator puts it after / const PIPE_SEPARATOR = '|' // ASCII 124 +const EARLIER_THAN_SLASH_SEPARATOR = DOT_SEPARATOR +const LATER_THAN_SLASH_SEPARATOR = GT_SEPARATOR + export const DEFAULT_NORMALIZATION_PLACES = 8; // Fixed width of a normalized number (with leading zeros) // Property escapes: @@ -51,9 +66,9 @@ export function getNormalizedNumber(s: string = '', separator?: string, places?: // guarantees correct order (/ = ASCII 47, | = ASCII 124) if (separator) { const components: Array = s.split(separator).filter(s => s) - return `${components.map((c) => prependWithZeros(c, places ?? DEFAULT_NORMALIZATION_PLACES)).join(PIPE_SEPARATOR)}//` + return `${components.map((c) => prependWithZeros(c, places ?? DEFAULT_NORMALIZATION_PLACES)).join(PIPE_SEPARATOR)}${SLASH_SEPARATOR}${SLASH_SEPARATOR}` } else { - return `${prependWithZeros(s, places ?? DEFAULT_NORMALIZATION_PLACES)}//` + return `${prependWithZeros(s, places ?? DEFAULT_NORMALIZATION_PLACES)}${SLASH_SEPARATOR}${SLASH_SEPARATOR}` } } @@ -97,9 +112,9 @@ export function getNormalizedRomanNumber(s: string, separator?: string, places?: // guarantees correct order (/ = ASCII 47, | = ASCII 124) if (separator) { const components: Array = s.split(separator).filter(s => s) - return `${components.map((c) => prependWithZeros(romanToIntStr(c), places ?? DEFAULT_NORMALIZATION_PLACES)).join(PIPE_SEPARATOR)}//` + return `${components.map((c) => prependWithZeros(romanToIntStr(c), places ?? DEFAULT_NORMALIZATION_PLACES)).join(PIPE_SEPARATOR)}${SLASH_SEPARATOR}${SLASH_SEPARATOR}` } else { - return `${prependWithZeros(romanToIntStr(s), places ?? DEFAULT_NORMALIZATION_PLACES)}//` + return `${prependWithZeros(romanToIntStr(s), places ?? DEFAULT_NORMALIZATION_PLACES)}${SLASH_SEPARATOR}${SLASH_SEPARATOR}` } } @@ -117,9 +132,76 @@ export function getNormalizedDate_NormalizerFn_for(separator: string, dayIdx: nu const monthValue = months ? `${1 + MONTHS.indexOf(components[monthIdx])}` : components[monthIdx] const month = prependWithZeros(monthValue, MONTH_POSITIONS) const year = prependWithZeros(components[yearIdx], YEAR_POSITIONS) - return `${year}-${month}-${day}//` + return `${year}-${month}-${day}${SLASH_SEPARATOR}${SLASH_SEPARATOR}` } } +export const getNormalizedDate_yyyy_mm_dd_NormalizerFn = getNormalizedDate_NormalizerFn_for('-', 2, 1, 0) +export const getNormalizedDate_yyyy_dd_mm_NormalizerFn = getNormalizedDate_NormalizerFn_for('-', 1, 2, 0) export const getNormalizedDate_dd_Mmm_yyyy_NormalizerFn = getNormalizedDate_NormalizerFn_for('-', 0, 1, 2, MONTHS) export const getNormalizedDate_Mmm_dd_yyyy_NormalizerFn = getNormalizedDate_NormalizerFn_for('-', 1, 0, 2, MONTHS) + +const DateExtractor_orderModifier_earlier_than = '-' +const DateExtractor_orderModifier_later_than = '+' + +const DateExtractor_yyyy_Www_mm_dd_Regex = /(\d{4})-W(\d{1,2}) \((\d{2})-(\d{2})\)/ +const DateExtractor_yyyy_Www_Regex = /(\d{4})-W(\d{1,2})([-+]?)/ + +// Matching groups +const YEAR_IDX = 1 +const WEEK_IDX = 2 +const MONTH_IDX = 3 +const DAY_IDX = 4 +const RELATIVE_ORDER_IDX = 3 // For the yyyy-Www only: yyyy-Www- or yyyy-Www+ + +const DECEMBER = 12 +const JANUARY = 1 + +export function getNormalizedDate_NormalizerFn_yyyy_Www_mm_dd(consumeWeek: boolean, weeksISO?: boolean) { + return (s: string): string | null => { + // Assumption - the regex date matched against input s, no extensive defensive coding needed + const matches = consumeWeek ? DateExtractor_yyyy_Www_Regex.exec(s) : DateExtractor_yyyy_Www_mm_dd_Regex.exec(s) + const yearStr = matches![YEAR_IDX] + let yearNumber = Number.parseInt(yearStr,10) + let monthNumber: number + let dayNumber: number + let separator = SLASH_SEPARATOR // different values enforce relative > < order of same dates + let useLastDayOfWeek: boolean = false + if (consumeWeek) { + const weekNumberStr = matches![WEEK_IDX] + const weekNumber = Number.parseInt(weekNumberStr, 10) + const orderModifier: string|undefined = matches![RELATIVE_ORDER_IDX] + if (orderModifier === DateExtractor_orderModifier_earlier_than) { + separator = EARLIER_THAN_SLASH_SEPARATOR + } else if (orderModifier === DateExtractor_orderModifier_later_than) { + separator = LATER_THAN_SLASH_SEPARATOR // Will also need to adjust the date to the last day of the week + useLastDayOfWeek = true + } + const dateForWeek = getDateForWeekOfYear(yearNumber, weekNumber, weeksISO, useLastDayOfWeek) + monthNumber = dateForWeek.getMonth()+1 // 1 - 12 + dayNumber = dateForWeek.getDate() // 1 - 31 + // Be careful with edge dates, which can belong to previous or next year + if (weekNumber === 1) { + if (monthNumber === DECEMBER) { + yearNumber-- + } + } + if (weekNumber >= 50) { + if (monthNumber === JANUARY) { + yearNumber++ + } + } + } else { // ignore week + monthNumber = Number.parseInt(matches![MONTH_IDX],10) + dayNumber = Number.parseInt(matches![DAY_IDX], 10) + } + return `${prependWithZeros(`${yearNumber}`, YEAR_POSITIONS)}` + + `-${prependWithZeros(`${monthNumber}`, MONTH_POSITIONS)}` + + `-${prependWithZeros(`${dayNumber}`, DAY_POSITIONS)}` + + `${separator}${SLASH_SEPARATOR}` + } +} + +export const getNormalizedDate_yyyy_Www_mm_dd_NormalizerFn = getNormalizedDate_NormalizerFn_yyyy_Www_mm_dd(false) +export const getNormalizedDate_yyyy_WwwISO_NormalizerFn = getNormalizedDate_NormalizerFn_yyyy_Www_mm_dd(true, true) +export const getNormalizedDate_yyyy_Www_NormalizerFn = getNormalizedDate_NormalizerFn_yyyy_Www_mm_dd(true, false) diff --git a/src/custom-sort/sorting-spec-processor.ts b/src/custom-sort/sorting-spec-processor.ts index 4ffdd10..0217f3e 100644 --- a/src/custom-sort/sorting-spec-processor.ts +++ b/src/custom-sort/sorting-spec-processor.ts @@ -19,9 +19,19 @@ import { DASH_SEPARATOR, Date_dd_Mmm_yyyy_RegexStr, Date_Mmm_dd_yyyy_RegexStr, + Date_yyyy_dd_mm_RegexStr, + Date_yyyy_mm_dd_RegexStr, + Date_yyyy_Www_mm_dd_RegexStr, + Date_yyyy_Www_RegexStr, + Date_yyyy_WwwISO_RegexStr, DOT_SEPARATOR, getNormalizedDate_dd_Mmm_yyyy_NormalizerFn, getNormalizedDate_Mmm_dd_yyyy_NormalizerFn, + getNormalizedDate_yyyy_dd_mm_NormalizerFn, + getNormalizedDate_yyyy_mm_dd_NormalizerFn, + getNormalizedDate_yyyy_Www_mm_dd_NormalizerFn, + getNormalizedDate_yyyy_Www_NormalizerFn, + getNormalizedDate_yyyy_WwwISO_NormalizerFn, getNormalizedNumber, getNormalizedRomanNumber, NumberRegexStr, @@ -36,10 +46,7 @@ import { MATCH_CHILDREN_2_SUFFIX, NO_PRIORITY } from "./folder-matching-rules" -import { - MDataExtractor, - tryParseAsMDataExtractorSpec -} from "./mdata-extractors"; +import {MDataExtractor, tryParseAsMDataExtractorSpec} from "./mdata-extractors"; interface ProcessingContext { folderPath: string @@ -352,8 +359,13 @@ const InlineRegexSymbol_Digit1: string = '\\d' const InlineRegexSymbol_Digit2: string = '\\[0-9]' const InlineRegexSymbol_0_to_3: string = '\\[0-3]' +const Date_yyyy_mm_dd_RegexSymbol: string = '\\[yyyy-mm-dd]' +const Date_yyyy_dd_mm_RegexSymbol: string = '\\[yyyy-dd-mm]' const Date_dd_Mmm_yyyy_RegexSymbol: string = '\\[dd-Mmm-yyyy]' const Date_Mmm_dd_yyyy_RegexSymbol: string = '\\[Mmm-dd-yyyy]' +const Date_yyyy_Www_mm_dd_RegexSymbol: string = '\\[yyyy-Www (mm-dd)]' +const Date_yyyy_Www_RegexSymbol: string = '\\[yyyy-Www]' +const Date_yyyy_WwwISO_RegexSymbol: string = '\\[yyyy-WwwISO]' const InlineRegexSymbol_CapitalLetter: string = '\\C' const InlineRegexSymbol_LowercaseLetter: string = '\\l' @@ -373,8 +385,13 @@ const sortingSymbolsArr: Array = [ escapeRegexUnsafeCharacters(CompoundRomanNumberDashRegexSymbol), escapeRegexUnsafeCharacters(WordInASCIIRegexSymbol), escapeRegexUnsafeCharacters(WordInAnyLanguageRegexSymbol), + escapeRegexUnsafeCharacters(Date_yyyy_mm_dd_RegexSymbol), + escapeRegexUnsafeCharacters(Date_yyyy_dd_mm_RegexSymbol), escapeRegexUnsafeCharacters(Date_dd_Mmm_yyyy_RegexSymbol), - escapeRegexUnsafeCharacters(Date_Mmm_dd_yyyy_RegexSymbol) + escapeRegexUnsafeCharacters(Date_Mmm_dd_yyyy_RegexSymbol), + escapeRegexUnsafeCharacters(Date_yyyy_Www_mm_dd_RegexSymbol), + escapeRegexUnsafeCharacters(Date_yyyy_WwwISO_RegexSymbol), + escapeRegexUnsafeCharacters(Date_yyyy_Www_RegexSymbol), ] const sortingSymbolsRegex = new RegExp(sortingSymbolsArr.join('|'), 'gi') @@ -442,8 +459,13 @@ export const CompoundDashRomanNumberNormalizerFn: NormalizerFn = (s: string) => export const NumberNormalizerFn: NormalizerFn = (s: string) => getNormalizedNumber(s) export const CompoundDotNumberNormalizerFn: NormalizerFn = (s: string) => getNormalizedNumber(s, DOT_SEPARATOR) export const CompoundDashNumberNormalizerFn: NormalizerFn = (s: string) => getNormalizedNumber(s, DASH_SEPARATOR) +export const Date_yyyy_mm_dd_NormalizerFn: NormalizerFn = (s: string) => getNormalizedDate_yyyy_mm_dd_NormalizerFn(s) +export const Date_yyyy_dd_mm_NormalizerFn: NormalizerFn = (s: string) => getNormalizedDate_yyyy_dd_mm_NormalizerFn(s) export const Date_dd_Mmm_yyyy_NormalizerFn: NormalizerFn = (s: string) => getNormalizedDate_dd_Mmm_yyyy_NormalizerFn(s) export const Date_Mmm_dd_yyyy_NormalizerFn: NormalizerFn = (s: string) => getNormalizedDate_Mmm_dd_yyyy_NormalizerFn(s) +export const Date_yyyy_Www_mm_dd_NormalizerFn: NormalizerFn = (s: string) => getNormalizedDate_yyyy_Www_mm_dd_NormalizerFn(s) +export const Date_yyyy_WwwISO_NormalizerFn: NormalizerFn = (s: string) => getNormalizedDate_yyyy_WwwISO_NormalizerFn(s) +export const Date_yyyy_Www_NormalizerFn: NormalizerFn = (s: string) => getNormalizedDate_yyyy_Www_NormalizerFn(s) export enum AdvancedRegexType { None, // to allow if (advancedRegex) @@ -455,8 +477,13 @@ export enum AdvancedRegexType { CompoundDashRomanNumber, WordInASCII, WordInAnyLanguage, + Date_yyyy_mm_dd, + Date_yyyy_dd_mm, Date_dd_Mmm_yyyy, - Date_Mmm_dd_yyyy + Date_Mmm_dd_yyyy, + Date_yyyy_Www_mm_dd_yyyy, + Date_yyyy_WwwISO, + Date_yyyy_Www } const sortingSymbolToRegexpStr: { [key: string]: RegExpSpecStr } = { @@ -501,6 +528,16 @@ const sortingSymbolToRegexpStr: { [key: string]: RegExpSpecStr } = { advancedRegexType: AdvancedRegexType.WordInAnyLanguage, unicodeRegex: true }, + [Date_yyyy_mm_dd_RegexSymbol]: { // Intentionally retain character case + regexpStr: Date_yyyy_mm_dd_RegexStr, + normalizerFn: Date_yyyy_mm_dd_NormalizerFn, + advancedRegexType: AdvancedRegexType.Date_yyyy_mm_dd + }, + [Date_yyyy_dd_mm_RegexSymbol]: { // Intentionally retain character case + regexpStr: Date_yyyy_dd_mm_RegexStr, + normalizerFn: Date_yyyy_dd_mm_NormalizerFn, + advancedRegexType: AdvancedRegexType.Date_yyyy_dd_mm + }, [Date_dd_Mmm_yyyy_RegexSymbol]: { // Intentionally retain character case regexpStr: Date_dd_Mmm_yyyy_RegexStr, normalizerFn: Date_dd_Mmm_yyyy_NormalizerFn, @@ -510,6 +547,21 @@ const sortingSymbolToRegexpStr: { [key: string]: RegExpSpecStr } = { regexpStr: Date_Mmm_dd_yyyy_RegexStr, normalizerFn: Date_Mmm_dd_yyyy_NormalizerFn, advancedRegexType: AdvancedRegexType.Date_Mmm_dd_yyyy + }, + [Date_yyyy_Www_mm_dd_RegexSymbol]: { // Intentionally retain character case + regexpStr: Date_yyyy_Www_mm_dd_RegexStr, + normalizerFn: Date_yyyy_Www_mm_dd_NormalizerFn, + advancedRegexType: AdvancedRegexType.Date_yyyy_Www_mm_dd_yyyy + }, + [Date_yyyy_WwwISO_RegexSymbol]: { // Intentionally retain character case + regexpStr: Date_yyyy_WwwISO_RegexStr, + normalizerFn: Date_yyyy_WwwISO_NormalizerFn, + advancedRegexType: AdvancedRegexType.Date_yyyy_WwwISO + }, + [Date_yyyy_Www_RegexSymbol]: { // Intentionally retain character case + regexpStr: Date_yyyy_Www_RegexStr, + normalizerFn: Date_yyyy_Www_NormalizerFn, + advancedRegexType: AdvancedRegexType.Date_yyyy_Www } } diff --git a/src/test/int/dates-in-names.int.test.ts b/src/test/int/dates-in-names.int.test.ts index 66487e5..b94a31d 100644 --- a/src/test/int/dates-in-names.int.test.ts +++ b/src/test/int/dates-in-names.int.test.ts @@ -1,5 +1,5 @@ import { - TAbstractFile, + TAbstractFile, TFile, TFolder, Vault } from "obsidian"; @@ -7,7 +7,11 @@ import { DEFAULT_FOLDER_CTIME, determineFolderDatesIfNeeded, determineSortingGroup, - FolderItemForSorting, OS_alphabetical, OS_byCreatedTime, ProcessingContext, sortFolderItems + FolderItemForSorting, + OS_alphabetical, + OS_byCreatedTime, + ProcessingContext, + sortFolderItems } from "../../custom-sort/custom-sort"; import { CustomSortGroupType, @@ -21,17 +25,20 @@ import { mockTFolderWithDateNamedChildren, TIMESTAMP_DEEP_NEWEST, TIMESTAMP_DEEP_OLDEST, + mockTFolderWithDateWeekNamedChildrenForISOvsUSweekNumberingTest, + mockTFolderWithDateWeekNamedChildren, mockTFile, mockTFolder, } from "../mocks"; import { SortingSpecProcessor } from "../../custom-sort/sorting-spec-processor"; describe('sortFolderItems', () => { - it('should correctly handle Mmm-dd-yyyy pattern in file names', () => { + it('should correctly handle Mmm-dd-yyyy pattern in file and folder names', () => { // given const processor: SortingSpecProcessor = new SortingSpecProcessor() const sortSpecTxt = -` ... \\[Mmm-dd-yyyy] +` + ... \\[Mmm-dd-yyyy] > a-z ` const PARENT_PATH = 'parent/folder/path' @@ -58,6 +65,224 @@ describe('sortFolderItems', () => { 'AAA Jan-01-2012' ]) }) + it('should correctly handle yyyy-Www (mm-dd) pattern in file and folder names', () => { + // given + const processor: SortingSpecProcessor = new SortingSpecProcessor() + const sortSpecTxt = +` + ... \\[yyyy-Www (mm-dd)] + < a-z + ------ +` + const PARENT_PATH = 'parent/folder/path' + const sortSpecsCollection = processor.parseSortSpecFromText( + sortSpecTxt.split('\n'), + PARENT_PATH, + 'file name with the sorting, irrelevant here' + ) + + const folder: TFolder = mockTFolderWithDateWeekNamedChildren(PARENT_PATH) + const sortSpec: CustomSortSpec = sortSpecsCollection?.sortSpecByPath![PARENT_PATH]! + + const ctx: ProcessingContext = {} + + // when + const result: Array = sortFolderItems(folder, folder.children, sortSpec, ctx, OS_alphabetical) + + // then + const orderedNames = result.map(f => f.name) + expect(orderedNames).toEqual([ + "GHI 2021-W1 (01-04)", + "DEF 2021-W9 (03-01).md", + "ABC 2021-W13 (03-29)", + "MNO 2021-W45 (11-08).md", + "JKL 2021-W52 (12-27).md", + "------.md" + ]) + }) + it('should correctly handle yyyy-WwwISO pattern in file and folder names', () => { + // given + const processor: SortingSpecProcessor = new SortingSpecProcessor() + const sortSpecTxt = +` + /+ ... \\[yyyy-Www (mm-dd)] + /+ ... \\[yyyy-WwwISO] + < a-z +` + const PARENT_PATH = 'parent/folder/path' + const sortSpecsCollection = processor.parseSortSpecFromText( + sortSpecTxt.split('\n'), + PARENT_PATH, + 'file name with the sorting, irrelevant here' + ) + + const folder: TFolder = mockTFolderWithDateWeekNamedChildrenForISOvsUSweekNumberingTest(PARENT_PATH) + const sortSpec: CustomSortSpec = sortSpecsCollection?.sortSpecByPath![PARENT_PATH]! + + const ctx: ProcessingContext = {} + + // when + const result: Array = sortFolderItems(folder, folder.children, sortSpec, ctx, OS_alphabetical) + + // then + // ISO standard of weeks numbering + const orderedNames = result.map(f => f.name) + expect(orderedNames).toEqual([ + 'E 2021-W1 (01-01)', + 'F ISO:2021-01-04 US:2020-12-28 2021-W1', + 'A 2021-W10 (03-05).md', + 'B ISO:2021-03-08 US:2021-03-01 2021-W10', + 'C 2021-W51 (12-17).md', + 'D ISO:2021-12-20 US:2021-12-13 2021-W51.md', + 'FFF2 ISO:2021-12-27 US:2021-12-20 2021-W52.md', + 'FFF1 ISO:2022-01-03 US:2021-12-27 2021-W53.md', + "------.md" + ]) + }) + it('should correctly handle yyyy-Www pattern in file and folder names', () => { + // given + const processor: SortingSpecProcessor = new SortingSpecProcessor() + const sortSpecTxt = +` + /+ ... \\[yyyy-Www (mm-dd)] + /+ ... \\[yyyy-Www] + > a-z + ... \\-d+ +` + const PARENT_PATH = 'parent/folder/path' + const sortSpecsCollection = processor.parseSortSpecFromText( + sortSpecTxt.split('\n'), + PARENT_PATH, + 'file name with the sorting, irrelevant here' + ) + + const folder: TFolder = mockTFolderWithDateWeekNamedChildrenForISOvsUSweekNumberingTest(PARENT_PATH) + const sortSpec: CustomSortSpec = sortSpecsCollection?.sortSpecByPath![PARENT_PATH]! + + const ctx: ProcessingContext = {} + + // when + const result: Array = sortFolderItems(folder, folder.children, sortSpec, ctx, OS_alphabetical) + + // then + // U.S. standard of weeks numbering + const orderedNames = result.map(f => f.name) + expect(orderedNames).toEqual([ + 'FFF1 ISO:2022-01-03 US:2021-12-27 2021-W53.md', + 'FFF2 ISO:2021-12-27 US:2021-12-20 2021-W52.md', + 'C 2021-W51 (12-17).md', + 'D ISO:2021-12-20 US:2021-12-13 2021-W51.md', + 'A 2021-W10 (03-05).md', + 'B ISO:2021-03-08 US:2021-03-01 2021-W10', + 'E 2021-W1 (01-01)', + 'F ISO:2021-01-04 US:2020-12-28 2021-W1', + "------.md" + ]) + }) + it('should correctly mix for sorting different date formats in file and folder names', () => { + // given + const processor: SortingSpecProcessor = new SortingSpecProcessor() + const sortSpecTxt = +` + /+ ... \\[yyyy-Www (mm-dd)] + /+ ... \\[yyyy-Www] + /+ ... mm-dd \\[yyyy-mm-dd] + /+ ... dd-mm \\[yyyy-dd-mm] + /+ ... \\[yyyy-mm-dd] + /+ ... \\[Mmm-dd-yyyy] + /+ \\[dd-Mmm-yyyy] ... + > a-z +` + const PARENT_PATH = 'parent/folder/path' + const sortSpecsCollection = processor.parseSortSpecFromText( + sortSpecTxt.split('\n'), + PARENT_PATH, + 'file name with the sorting, irrelevant here' + ) + + const folder: TFolder = mockTFolderWithDateWeekNamedChildrenForISOvsUSweekNumberingTest(PARENT_PATH) + folder.children.push(...[ + mockTFile('File 2021-12-14', 'md'), + mockTFile('File mm-dd 2020-12-30', 'md'), // mm-dd + mockTFile('File dd-mm 2020-31-12', 'md'), // dd-mm + mockTFile('File Mar-08-2021', 'md'), + mockTFile('18-Dec-2021 file', 'md'), + ]) + + const sortSpec: CustomSortSpec = sortSpecsCollection?.sortSpecByPath![PARENT_PATH]! + + const ctx: ProcessingContext = {} + + // when + const result: Array = sortFolderItems(folder, folder.children, sortSpec, ctx, OS_alphabetical) + + // then + // U.S. standard of weeks numbering + const orderedNames = result.map(f => f.name) + expect(orderedNames).toEqual([ + 'FFF1 ISO:2022-01-03 US:2021-12-27 2021-W53.md', + 'FFF2 ISO:2021-12-27 US:2021-12-20 2021-W52.md', + "18-Dec-2021 file.md", + 'C 2021-W51 (12-17).md', + "File 2021-12-14.md", + 'D ISO:2021-12-20 US:2021-12-13 2021-W51.md', + "File Mar-08-2021.md", + 'A 2021-W10 (03-05).md', + 'B ISO:2021-03-08 US:2021-03-01 2021-W10', + 'E 2021-W1 (01-01)', + "File dd-mm 2020-31-12.md", + "File mm-dd 2020-12-30.md", + 'F ISO:2021-01-04 US:2020-12-28 2021-W1', + "------.md" + ]) + }) + it('should correctly order the week number with specifiers', () => { + // given + const processor: SortingSpecProcessor = new SortingSpecProcessor() + const sortSpecTxt = + ` + /+ \\[yyyy-Www] + /+ \\[yyyy-mm-dd] + > a-z +` + const PARENT_PATH = 'parent/folder/path' + const sortSpecsCollection = processor.parseSortSpecFromText( + sortSpecTxt.split('\n'), + PARENT_PATH, + 'file name with the sorting, irrelevant here' + ) + + const folder: TFolder = mockTFolder(PARENT_PATH,[ + // ISO and U.S. standard for 2025 give the same week numbers (remark for clarity) + mockTFile('2025-03-09', 'md'), // sunday of W10 + mockTFile('2025-W11-', 'md'), // earlier than monday of W11 + mockTFile('2025-03-10', 'md'), // monday W11 + mockTFile('2025-W11', 'md'), // monday of W11 + mockTFile('2025-03-16', 'md'), // sunday W11 + mockTFile('2025-W11+', 'md'), // later than sunday W11 // expected + mockTFile('2025-03-17', 'md'), // monday of W12 + ]) + + const sortSpec: CustomSortSpec = sortSpecsCollection?.sortSpecByPath![PARENT_PATH]! + + const ctx: ProcessingContext = {} + + // when + const result: Array = sortFolderItems(folder, folder.children, sortSpec, ctx, OS_alphabetical) + + // then + // U.S. standard of weeks numbering + const orderedNames = result.map(f => f.name) + expect(orderedNames).toEqual([ + "2025-03-17.md", + '2025-W11+.md', + "2025-03-16.md", + '2025-W11.md', + "2025-03-10.md", + "2025-W11-.md", + "2025-03-09.md", + ]) + }) }) diff --git a/src/test/mocks.ts b/src/test/mocks.ts index a5853ef..0f34f38 100644 --- a/src/test/mocks.ts +++ b/src/test/mocks.ts @@ -65,3 +65,33 @@ export const mockTFolderWithDateNamedChildren = (name: string): TFolder => { return mockTFolder(name, [child1, child2, child3, child4]) } + +export const mockTFolderWithDateWeekNamedChildren = (name: string): TFolder => { + // Assume ISO week numbers + const child0: TFile = mockTFile('------', 'md') + const child1: TFolder = mockTFolder('ABC 2021-W13 (03-29)') + const child2: TFile = mockTFile('DEF 2021-W9 (03-01)', 'md') + const child3: TFolder = mockTFolder('GHI 2021-W1 (01-04)') + const child4: TFile = mockTFile('JKL 2021-W52 (12-27)', 'md') + const child5: TFile = mockTFile('MNO 2021-W45 (11-08)', 'md') + + return mockTFolder(name, [child0, child1, child2, child3, child4, child5]) +} + +export const mockTFolderWithDateWeekNamedChildrenForISOvsUSweekNumberingTest = (name: string): TFolder => { + // Tricky to test handling of both ISO and U.S. weeks numbering. + // Sample year with different week numbers in ISO vs. U.S. is 2021 with 1st Jan on Fri, ISO != U.S. + // Plain files and folder names to match both week-only and week+date syntax + // Their relative ordering depends on week numbering + const child0: TFile = mockTFile('------', 'md') + const child1: TFile = mockTFile('A 2021-W10 (03-05)', 'md') // Tue date, (ISO) week number invalid, ignored + const child2: TFolder = mockTFolder('B ISO:2021-03-08 US:2021-03-01 2021-W10') + const child3: TFile = mockTFile('C 2021-W51 (12-17)', 'md') // Tue date, (ISO) week number invalid, ignored + const child4: TFile = mockTFile('D ISO:2021-12-20 US:2021-12-13 2021-W51', 'md') + const child5: TFolder = mockTFolder('E 2021-W1 (01-01)') // Tue date, to (ISO) week number invalid, ignored + const child6: TFolder = mockTFolder('F ISO:2021-01-04 US:2020-12-28 2021-W1') + const child7: TFile = mockTFile('FFF2 ISO:2021-12-27 US:2021-12-20 2021-W52', 'md') + const child8: TFile = mockTFile('FFF1 ISO:2022-01-03 US:2021-12-27 2021-W53', 'md') // Invalid week, should fall to next year + + return mockTFolder(name, [child0, child1, child2, child3, child4, child5, child6, child7, child8]) +} diff --git a/src/test/unit/matchers.spec.ts b/src/test/unit/matchers.spec.ts index 7515e37..7ef0ce4 100644 --- a/src/test/unit/matchers.spec.ts +++ b/src/test/unit/matchers.spec.ts @@ -10,7 +10,12 @@ import { CompoundRomanNumberDotRegexStr, CompoundRomanNumberDashRegexStr, WordInASCIIRegexStr, - WordInAnyLanguageRegexStr, getNormalizedDate_dd_Mmm_yyyy_NormalizerFn + WordInAnyLanguageRegexStr, + getNormalizedDate_dd_Mmm_yyyy_NormalizerFn, + getNormalizedDate_yyyy_Www_NormalizerFn, + getNormalizedDate_yyyy_Www_mm_dd_NormalizerFn, + getNormalizedDate_yyyy_dd_mm_NormalizerFn, + getNormalizedDate_yyyy_mm_dd_NormalizerFn } from "../../custom-sort/matchers"; describe('Plain numbers regexp', () => { @@ -431,3 +436,41 @@ describe('getNormalizedDate_dd_Mmm_yyyy_NormalizerFn', () => { expect(getNormalizedDate_dd_Mmm_yyyy_NormalizerFn(s)).toBe(out) }) }) + +describe('getNormalizedDate_yyyy_dd_mm_NormalizerFn', () => { + const params = [ + ['2012-13-01', '2012-01-13//', '2012-13-01//'], + ['0001-03-02', '0001-02-03//', '0001-03-02//'], + ['7777-09-1234', '7777-1234-09//', '7777-09-1234//'], + ]; + it.each(params)('>%s< should become %s', (s: string, outForDDMM: string, outForMMDD: string) => { + expect(getNormalizedDate_yyyy_dd_mm_NormalizerFn(s)).toBe(outForDDMM) + expect(getNormalizedDate_yyyy_mm_dd_NormalizerFn(s)).toBe(outForMMDD) + }) +}) + +describe('getNormalizedDate_yyyy_Www_mm_dd_NormalizerFn', () => { + const params = [ + ['2012-W0 (01-13)', '2012-01-13//'], + ['0002-W12 (02-03)', '0002-02-03//'], + ]; + it.each(params)('>%s< should become %s', (s: string, out: string) => { + expect(getNormalizedDate_yyyy_Www_mm_dd_NormalizerFn(s)).toBe(out) + }) +}) + +describe('getNormalizedDate_yyyy_Www_NormalizerFn', () => { + /* ORDER for week numbers vs. dates of 1st day / last day of the week: + W1 - exactly on the first day of 1st week - the actual title then decides about relative order + W1- - before the first day of 1st week, yet after the last day of prev week) + W1+ - after the last day of 1st week, yet before the first day of next week) + */ + const params = [ + ['2012-W1', '2011-12-26//'], + ['2012-W1+', '2012-01-01>/'], + ['2012-W1-', '2011-12-26./'], + ]; + it.each(params)('>%s< should become %s', (s: string, out: string) => { + expect(getNormalizedDate_yyyy_Www_NormalizerFn(s)).toBe(out) + }) +}) diff --git a/src/test/unit/sorting-spec-processor.spec.ts b/src/test/unit/sorting-spec-processor.spec.ts index fb358cc..891013c 100644 --- a/src/test/unit/sorting-spec-processor.spec.ts +++ b/src/test/unit/sorting-spec-processor.spec.ts @@ -7,6 +7,11 @@ import { convertPlainStringToRegex, Date_dd_Mmm_yyyy_NormalizerFn, Date_Mmm_dd_yyyy_NormalizerFn, + Date_yyyy_dd_mm_NormalizerFn, + Date_yyyy_mm_dd_NormalizerFn, + Date_yyyy_Www_mm_dd_NormalizerFn, + Date_yyyy_Www_NormalizerFn, + Date_yyyy_WwwISO_NormalizerFn, detectSortingSymbols, escapeRegexUnsafeCharacters, extractSortingSymbol, @@ -367,6 +372,23 @@ const expectedSortSpecsExampleA: { [key: string]: CustomSortSpec } = { } } +const txtInputExampleSortingSymbols: string = ` +/folders Chapter \\.d+ ... +/:files ...section \\-r+. +% Appendix \\-d+ (attachments) +Plain syntax\\R+ ... works? +And this kind of... \\D+plain syntax??? +Here goes ASCII word \\a+ +\\A+. is for any modern language word +\\[dd-Mmm-yyyy] for the specific date format of 12-Apr-2024 +\\[Mmm-dd-yyyy] for the specific date format of Apr-01-2024 +\\[yyyy-Www (mm-dd)] Week number ignored +Week number interpreted in ISO standard \\[yyyy-WwwISO] +Week number interpreted in U.S. standard \\[yyyy-Www] +\\[yyyy-mm-dd] plain spec 1 +\\[yyyy-dd-mm] plain spec 2 +` + const expectedSortSpecsExampleSortingSymbols: { [key: string]: CustomSortSpec } = { "mock-folder": { groups: [{ @@ -427,26 +449,44 @@ const expectedSortSpecsExampleSortingSymbols: { [key: string]: CustomSortSpec } regex: /^ *((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-[0-3]*[0-9]-\d{4}) for the specific date format of Apr\-01\-2024$/i, normalizerFn: Date_Mmm_dd_yyyy_NormalizerFn } + }, { + type: CustomSortGroupType.ExactName, + regexPrefix: { + regex: /^ *(\d{4}-W[0-5]*[0-9] \([0-3]*[0-9]-[0-3]*[0-9]\)) Week number ignored$/i, + normalizerFn: Date_yyyy_Www_mm_dd_NormalizerFn + } + }, { + type: CustomSortGroupType.ExactName, + regexPrefix: { + regex: /^Week number interpreted in ISO standard *(\d{4}-W[0-5]*[0-9][-+]?)$/i, + normalizerFn: Date_yyyy_WwwISO_NormalizerFn + } + }, { + type: CustomSortGroupType.ExactName, + regexPrefix: { + regex: /^Week number interpreted in U\.S\. standard *(\d{4}-W[0-5]*[0-9][-+]?)$/i, + normalizerFn: Date_yyyy_Www_NormalizerFn + } + }, { + type: CustomSortGroupType.ExactName, + regexPrefix: { + regex: /^ *(\d{4}-[0-3]*[0-9]-[0-3]*[0-9]) plain spec 1$/i, + normalizerFn: Date_yyyy_mm_dd_NormalizerFn + } + }, { + type: CustomSortGroupType.ExactName, + regexPrefix: { + regex: /^ *(\d{4}-[0-3]*[0-9]-[0-3]*[0-9]) plain spec 2$/i, + normalizerFn: Date_yyyy_dd_mm_NormalizerFn + } }, { type: CustomSortGroupType.Outsiders }], targetFoldersPaths: ['mock-folder'], - outsidersGroupIdx: 9 + outsidersGroupIdx: 14 } } -const txtInputExampleSortingSymbols: string = ` -/folders Chapter \\.d+ ... -/:files ...section \\-r+. -% Appendix \\-d+ (attachments) -Plain syntax\\R+ ... works? -And this kind of... \\D+plain syntax??? -Here goes ASCII word \\a+ -\\A+. is for any modern language word -\\[dd-Mmm-yyyy] for the specific date format of 12-Apr-2024 -\\[Mmm-dd-yyyy] for the specific date format of Apr-01-2024 -` - // Tricky elements captured: // - Order a-z. for by metadata is transformed to a-z (there is no notion of 'file extension' in metadata values) @@ -520,8 +560,6 @@ const expectedSortSpecsExampleMDataExtractors2: { [key: string]: CustomSortSpec } } - - describe('SortingSpecProcessor', () => { let processor: SortingSpecProcessor; beforeEach(() => { diff --git a/src/test/unit/week-of-year.spec.ts b/src/test/unit/week-of-year.spec.ts new file mode 100644 index 0000000..16eba99 --- /dev/null +++ b/src/test/unit/week-of-year.spec.ts @@ -0,0 +1,55 @@ +import {_unitTests, getDateForWeekOfYear} from "../../utils/week-of-year" + +const paramsForWeekOf1stOfJan = [ + [2015,'2014-12-29T00:00:00.000Z','same as U.S.'], // 1st Jan on Thu, ISO = U.S. + [2020,'2019-12-30T00:00:00.000Z','same as U.S.'], // 1st Jan on Wed, ISO = U.S. + [2021,'2020-12-28T00:00:00.000Z','2021-01-04T00:00:00.000Z'], // 1st Jan on Fri, ISO != U.S. + [2022,'2021-12-27T00:00:00.000Z','2022-01-03T00:00:00.000Z'], // 1st Jan on Sat, ISO != U.S. + [2023,'2022-12-26T00:00:00.000Z','2023-01-02T00:00:00.000Z'], // 1st Jan on Sun, ISO != U.S. + [2024,'2024-01-01T00:00:00.000Z','same as U.S.'], // 1st Jan on Mon, ISO = U.S. + [2025,'2024-12-30T00:00:00.000Z','same as U.S.'] // 1st Jan on Wed, ISO = U.S. +] + +const paramsFor10thWeek = [ + [2019,'2019-03-04T00:00:00.000Z','same as U.S.'], + [1999,'1999-03-01T00:00:00.000Z','1999-03-08T00:00:00.000Z'], + [1683,'1683-03-01T00:00:00.000Z','1683-03-08T00:00:00.000Z'], + [1410,'1410-03-05T00:00:00.000Z','same as U.S.'], + [1996,'1996-03-04T00:00:00.000Z','same as U.S.'], + [2023,'2023-02-27T00:00:00.000Z','2023-03-06T00:00:00.000Z'], + [2025,'2025-03-03T00:00:00.000Z','same as U.S.'] +] + +describe('calculateMondayDateIn2stWeekOfYear', () => { + it.each(paramsForWeekOf1stOfJan)('year >%s< should result in %s (U.S.) and %s (ISO)', (year: number, dateOfMondayUS: string, dateOfMondayISO: string) => { + const dateUS = new Date(dateOfMondayUS).getTime() + const dateISO = 'same as U.S.' === dateOfMondayISO ? dateUS : new Date(dateOfMondayISO).getTime() + const mondayData = _unitTests.calculateMondayDateIn2stWeekOfYear(year) + expect(mondayData.mondayDateOf1stWeekUS).toBe(dateUS) + expect(mondayData.mondayDateOf1stWeekISO).toBe(dateISO) + }) +}) + +describe('getDateForWeekOfYear', () => { + it.each(paramsForWeekOf1stOfJan)('For year >%s< 1st week should start on %s (U.S.) and %s (ISO)', (year: number, dateOfMondayUS: string, dateOfMondayISO: string) => { + const dateUS = new Date(dateOfMondayUS) + const dateISO = 'same as U.S.' === dateOfMondayISO ? dateUS : new Date(dateOfMondayISO) + expect(getDateForWeekOfYear(year, 1)).toStrictEqual(dateUS) + expect(getDateForWeekOfYear(year, 1, true)).toStrictEqual(dateISO) + }) + it.each(paramsFor10thWeek)('For year >%s< 10th week should start on %s (U.S.) and %s (ISO)', (year: number, dateOfMondayUS: string, dateOfMondayISO: string) => { + const dateUS = new Date(dateOfMondayUS) + const dateISO = 'same as U.S.' === dateOfMondayISO ? dateUS : new Date(dateOfMondayISO) + expect(getDateForWeekOfYear(year, 10)).toStrictEqual(dateUS) + expect(getDateForWeekOfYear(year, 10, true)).toStrictEqual(dateISO) + }) + it('should correctly handle edge case - a year spanning 54 weeks (leap year starting on Sun)', () => { + const USstandard = false + const SUNDAY = true + // This works in U.S. standard only, where 1st week can start on Sunday + expect(getDateForWeekOfYear(2012,1)).toStrictEqual(new Date('2011-12-26T00:00:00.000Z')) + expect(getDateForWeekOfYear(2012,1, USstandard, SUNDAY)).toStrictEqual(new Date('2012-01-01T00:00:00.000Z')) + expect(getDateForWeekOfYear(2012,54)).toStrictEqual(new Date('2012-12-31T00:00:00.000Z')) + expect(getDateForWeekOfYear(2012,54, USstandard, SUNDAY)).toStrictEqual(new Date('2013-01-06T00:00:00.000Z')) + }) +}) diff --git a/src/utils/week-of-year.ts b/src/utils/week-of-year.ts new file mode 100644 index 0000000..e93b8df --- /dev/null +++ b/src/utils/week-of-year.ts @@ -0,0 +1,63 @@ + +// Cache of start of years and number of days in the 1st week +interface MondayCache { + year: number // full year, e.g. 2015 + mondayDateOf1stWeekUS: number // U.S. standard, the 1st of Jan determines the first week, monday can be in Dec of previous year + sundayDateOf1stWeekUS: number + mondayDateOf1stWeekISO: number // ISO standard, when the first Thursday of the year determines week numbering + sundayDateOf1stWeekISO: number +} + +type YEAR = number +const DAY_OF_MILIS = 60*60*24*1000 +const DAYS_IN_WEEK = 7 + +const MondaysCache: { [key: YEAR]: MondayCache } = {} + +const calculateMondayDateIn1stWeekOfYear = (year: number): MondayCache => { + const firstSecondOfYear = new Date(`${year}-01-01T00:00:00.000Z`) + const SUNDAY = 0 + const MONDAY = 1 + const THURSDAY = 4 + const FRIDAY = 5 + const SATURDAY = 6 + + const dayOfWeek = firstSecondOfYear.getDay() + let daysToPrevMonday: number = 0 // For the Monday itself + if (dayOfWeek === SUNDAY) { // Sunday + daysToPrevMonday = DAYS_IN_WEEK - 1 + } else if (dayOfWeek > MONDAY) { // Tue - Sat + daysToPrevMonday = dayOfWeek - MONDAY + } + + // for U.S. the first week is the one with Jan 1st, + // for ISO standard, the first week is the one which contains the 1st Thursday of the year + const useISOoffset = [FRIDAY, SATURDAY, SUNDAY].includes(dayOfWeek) ? DAYS_IN_WEEK : 0 + + return { + year: year, + mondayDateOf1stWeekUS: new Date(firstSecondOfYear).setDate(firstSecondOfYear.getDate() - daysToPrevMonday), + sundayDateOf1stWeekUS: new Date(firstSecondOfYear).setDate(firstSecondOfYear.getDate() - daysToPrevMonday + DAYS_IN_WEEK - 1), + mondayDateOf1stWeekISO: new Date(firstSecondOfYear).setDate(firstSecondOfYear.getDate() - daysToPrevMonday + useISOoffset), + sundayDateOf1stWeekISO: new Date(firstSecondOfYear).setDate(firstSecondOfYear.getDate() - daysToPrevMonday + useISOoffset + DAYS_IN_WEEK - 1), + } +} + +// Week number = 1 to 54, U.S. standard by default, can also work in ISO (parameter driven) +export const getDateForWeekOfYear = (year: number, weekNumber: number, useISO?: boolean, sunday?: boolean): Date => { + const WEEK_OF_MILIS = DAYS_IN_WEEK * DAY_OF_MILIS + const dataOfMondayIn1stWeekOfYear = (MondaysCache[year] ??= calculateMondayDateIn1stWeekOfYear(year)) + const mondayOfTheRequestedWeek = + (useISO ? dataOfMondayIn1stWeekOfYear.mondayDateOf1stWeekISO : dataOfMondayIn1stWeekOfYear.mondayDateOf1stWeekUS) + + (weekNumber-1)*WEEK_OF_MILIS + + const sundayOfTheRequestedWeek = + (useISO ? dataOfMondayIn1stWeekOfYear.sundayDateOf1stWeekISO : dataOfMondayIn1stWeekOfYear.sundayDateOf1stWeekUS) + + (weekNumber-1)*WEEK_OF_MILIS + + return new Date(sunday ? sundayOfTheRequestedWeek : mondayOfTheRequestedWeek) +} + +export const _unitTests = { + calculateMondayDateIn2stWeekOfYear: calculateMondayDateIn1stWeekOfYear +}