diff --git a/scripts/format-changelog.js b/scripts/format-changelog.js new file mode 100644 index 0000000000..e2949079c4 --- /dev/null +++ b/scripts/format-changelog.js @@ -0,0 +1,304 @@ +const fs = require('fs'); + +// This is also the order expected for each version. +const ChangeTypes = ["Headline feature", "Feature", "Improved", "Change", "Removed", "Fix"]; +// A list of change types that were previously used but aren't accepted anymore, will be replaced with the value in the map. +const ChangeTypeSubstitutions = { + "Improve": "Improved", +}; +const HeaderSeperator = "------------------------------------------------------------------------"; + +function reportError(ctx, message) { + console.error(`Error on line ${ctx.lineIndex}: ${message}`); + process.exit(-1); +} + +function reportLineError(line, message) { + console.error(`Error on line ${line.index + 1}: ${message}`); + process.exit(-1); +} + +function consumeLine(ctx) { + if (ctx.lineIndex >= ctx.lines.length) { + reportError(ctx, "Unexpected end of file"); + } + const line = ctx.lines[ctx.lineIndex]; + ctx.lineIndex++; + return line; +} + +function peekLine(ctx) { + if (ctx.lineIndex >= ctx.lines.length) { + return null; + } + return ctx.lines[ctx.lineIndex]; +} + +function readVersionHeader(ctx) { + // Extract the version number. + const versionLine = consumeLine(ctx); + const versionMatch = versionLine.text.match(/^((\d+\.\d+\.\d+(\.\d+)?(-\w+)?) \((\d{4}-\d{2}-\d{2}|in development)\))$/); + if (versionMatch == null) { + reportLineError(versionLine, "Expected a version number"); + } + + // Check the version seperator + const versionSeperator = consumeLine(ctx); + if (versionSeperator.text != HeaderSeperator) { + reportLineError(versionSeperator, "Expected version seperator"); + } + + // Check for optional headline, this means the there is text not starting with - and an empty line before + // the first entry. + var headline = peekLine(ctx); + if (headline != null && headline.text != "" && !headline.text.startsWith("-")) { + consumeLine(ctx); + + // Check for empty line + const emptyLine = peekLine(ctx); + if (emptyLine == null || emptyLine.text != "") { + reportError(ctx, "Expected empty line after headline"); + } + + consumeLine(ctx); + headline = headline.text; + } else { + headline = ""; + } + + return { + version: versionMatch[2], + date: versionMatch[5], + headline: headline, + }; +} + +function isValidReference(ref) { + const regex = /^\w*#\d+( \(partial\))?$/; + return regex.test(ref); +} + +function readVersionEntry(ctx, rawEntry, versionInfo) { + // Check for the '- ' prefix + var text = rawEntry.text; + if (!text.startsWith("- ")) { + reportLineError(rawEntry, "Expected entry prefix '- '"); + } + text = text.substr(2); + + // Extract the change type + const changeTypeIdx = text.indexOf(":"); + if (changeTypeIdx == -1) { + reportLineError(rawEntry, "Expected change type"); + } + var changeType = text.substring(0, changeTypeIdx).trim(); + text = text.substring(changeTypeIdx + 1).trim(); + + // Check if changeType is valid + if (!ChangeTypes.includes(changeType)) { + // Check for substitution + if (changeType in ChangeTypeSubstitutions) { + changeType = ChangeTypeSubstitutions[changeType]; + } else { + reportLineError(rawEntry, `Invalid change type '${changeType}', types can be ${ChangeTypes.join(", ")}`); + } + } + + // Extract the references. + var refs = []; + if (text.startsWith("[")) { + const refsEndIdx = text.indexOf("]"); + if (refsEndIdx == -1) { + reportLineError(rawEntry, "Open reference bracket '[' without closing bracket ']'"); + } + refs = text.substring(1, refsEndIdx).split(",").map(ref => ref.trim()); + text = text.substring(refsEndIdx + 1).trim(); + } else { + // Text must start with '[', allow having this missing in older versions. + if (versionInfo.date == "in development") { + reportLineError(rawEntry, "Expected reference [#REF]"); + } + } + + // Check if reference is correct, can be '#123' or 'project#123' + for (const ref of refs) { + if(!isValidReference(ref)) { + reportLineError(rawEntry, `Invalid reference '${ref}', must be '#123' or 'project#123'`); + } + } + + const result = { + changeType: changeType, + refs: refs, + description: text.trim(), + }; + return result; +} + +function readVersionEntries(ctx, versionInfo) { + var entries = []; + while (ctx.lineIndex < ctx.lines.length) { + const rawEntry = consumeLine(ctx); + // Empty line means end of version block. + if (rawEntry.text == "") { + break; + } + const entry = readVersionEntry(ctx, rawEntry, versionInfo); + entries.push(entry); + } + return entries; +} + +function readVersionBlock(ctx) { + const versionInfo = readVersionHeader(ctx); + const entries = readVersionEntries(ctx, versionInfo); + return { + version: versionInfo.version, + date: versionInfo.date, + headline: versionInfo.headline, + entries: entries + }; +} + +function parseChangelog(changelog) { + // Split the changelog into lines but also keep the line numbers. + const lines = changelog.split('\n').map((text, index) => ({ text, index })); + + // Remove duplicate empty lines. + for (var i = 0; i < lines.length; i++) { + if (lines[i].text == "" && (i == 0 || lines[i - 1].text == "")) { + lines.splice(i, 1); + i--; + } + } + + var ctx = { + lines, + lineIndex: 0, + versions: [] + }; + + while (ctx.lineIndex < ctx.lines.length) { + const versionBlock = readVersionBlock(ctx); + ctx.versions.push(versionBlock); + } + + return ctx.versions; +} + +function referenceSort(a, b) { + // Extract the number from the reference + const numA = parseInt(a.replace(/\D/g, ''), 10); + const numB = parseInt(b.replace(/\D/g, ''), 10); + + // Check if both are direct references or subproject references + const isDirectA = a.startsWith('#'); + const isDirectB = b.startsWith('#'); + + if (isDirectA && isDirectB) { + // If both are direct references, sort by the number + return numA - numB; + } else if (!isDirectA && !isDirectB) { + // If both are subproject references, sort by the name and then the number + const nameA = a.split('#')[0]; + const nameB = b.split('#')[0]; + if (nameA.toLowerCase() < nameB.toLowerCase()) return -1; + if (nameA.toLowerCase() > nameB.toLowerCase()) return 1; + return numA - numB; + } else { + // If one is a direct reference and the other is a subproject reference, + // the direct reference should come first + return isDirectA ? -1 : 1; + } +} + +function reorderGroups(a, b) { + const changeTypeDiff = ChangeTypes.indexOf(a.changeType) - ChangeTypes.indexOf(b.changeType); + if (changeTypeDiff != 0) { + return changeTypeDiff; + } + // If changeType is identical, sort based on reference + const hasMainRefA = a.refs.length > 0 && a.refs[0].startsWith("#"); + const hasMainRefB = b.refs.length > 0 && b.refs[0].startsWith("#"); + // Sort by main reference number. + if (hasMainRefA && hasMainRefB) { + const numA = parseInt(a.refs[0].replace(/\D/g, ''), 10); + const numB = parseInt(b.refs[0].replace(/\D/g, ''), 10); + return numA - numB; + } else if (hasMainRefA && !hasMainRefB) { + return -1; + } else if (!hasMainRefA && hasMainRefB) { + return 1; + } else { + // Sort by reference name. + const nameA = a.refs.length > 0 ? a.refs[0].split('#')[0] : ""; + const nameB = b.refs.length > 0 ? b.refs[0].split('#')[0] : ""; + if (nameA.toLowerCase() < nameB.toLowerCase()) return -1; + if (nameA.toLowerCase() > nameB.toLowerCase()) return 1; + return 0; + } +} + +function cleanupVersionBlock(version) { + // Sort the references first, numbers come before sub project refs. + version.entries.forEach(entry => entry.refs.sort((a, b) => referenceSort(a, b))); + + // Sort the entries by change type. + version.entries.sort((a, b) => reorderGroups(a, b)); + + // Ensure entries end with a dot. + version.entries.forEach(entry => { + if (!entry.description.endsWith(".")) { + entry.description += "."; + } + }); +} + +function cleanupVersions(versions) { + for (const version of versions) { + cleanupVersionBlock(version); + } +} + +function printChangelog(versions) { + for (const version of versions) { + console.log(`${version.version} (${version.date})`); + console.log(HeaderSeperator); + if (version.headline != "") { + console.log(version.headline); + console.log(); + } + for (const entry of version.entries) { + var line = `- ${entry.changeType}: `; + if (entry.refs.length > 0) { + line += `[${entry.refs.join(", ")}] `; + } + line += `${entry.description}`; + console.log(line); + } + console.log(); + } +} + +function checkLineEndings(changelog) { + if (changelog.indexOf("\r\n") != -1) { + console.error("Changelog contains Windows line endings, please convert to Unix line endings."); + process.exit(-1); + } +} + +const filePath = process.argv[2]; +fs.readFile(filePath, 'utf8', (err, data) => { + if (err) { + console.error('Error reading the file:', err); + process.exit(-1); + } + + checkLineEndings(data); + + var versions = parseChangelog(data); + + cleanupVersions(versions); + + printChangelog(versions); +}); \ No newline at end of file