Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .eslintrc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ rules:
- error
- always-multiline
consistent-return: 0
curly:
- error
- all
function-paren-newline:
- error
- multiline
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

All changes that impact users of this module are documented in this file, in the [Common Changelog](https://common-changelog.org) format with some additional specifications defined in the CONTRIBUTING file. This codebase adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased [major]

> Development of this release was supported by [Reset Tech](https://www.reset.tech).

### Added

- Add `GET /feed` endpoint on the Collection API exposing an Atom feed of the latest version changes across the whole collection
- Add `GET /feed/:serviceId` endpoint on the Collection API exposing an Atom feed scoped to a single service
- Add `GET /feed/:serviceId/:termsType` endpoint on the Collection API exposing an Atom feed scoped to a single service and terms type
- Add [`@opentermsarchive/engine.collection-api.feed.limit`](https://docs.opentermsarchive.org/collections/reference/configuration/) configuration option controlling the maximum number of entries returned by feed endpoints (default: `100`)

### Changed

- **Breaking:** Resolve `serviceId` path parameter case-sensitively on the `GET /service/:serviceId` endpoint, in line with the documented service ID format; clients relying on case-insensitive matching must now use the exact ID casing

## 11.0.2 - 2026-04-14

> Development of this release was supported by [Reset Tech](https://www.reset.tech).
Expand Down
5 changes: 5 additions & 0 deletions config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@
},
"dataset": {
"publishingSchedule": "30 8 * * MON"
},
"collection-api": {
"feed": {
"limit": 100
}
}
}
}
5 changes: 4 additions & 1 deletion config/test.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@
},
"collection-api": {
"port": 3000,
"basePath": "/collection-api"
"basePath": "/collection-api",
"feed": {
"limit": 3
}
}
}
}
33 changes: 16 additions & 17 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@
"swagger-ui-express": "^5.0.1",
"turndown": "^7.2.1",
"winston": "^3.17.0",
"winston-mail": "^2.0.0"
"winston-mail": "^2.0.0",
"xml-js": "^1.6.11"
},
"devDependencies": {
"@commitlint/cli": "^19.8.1",
Expand Down
2 changes: 1 addition & 1 deletion scripts/reporter/duplicate/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async function removeDuplicateIssues() {
}

for (const [ title, duplicateIssues ] of issuesByTitle) {
if (duplicateIssues.length === 1) continue;
if (duplicateIssues.length === 1) { continue; }

const originalIssue = duplicateIssues.reduce((oldest, current) => (new Date(current.created_at) < new Date(oldest.created_at) ? current : oldest));

Expand Down
2 changes: 1 addition & 1 deletion src/archivist/collection/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('Collection', () => {
try {
metadataBackup = await fs.readFile(metadataPath, 'utf8');
} catch (error) {
if (error.code !== 'ENOENT') throw error;
if (error.code !== 'ENOENT') { throw error; }
}
});

Expand Down
5 changes: 5 additions & 0 deletions src/archivist/recorder/repositories/git/dataMapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ function generateFileName(termsType, documentId, extension) {
}

export function generateFilePath(serviceId, termsType, documentId, mimeType) {
// If only serviceId is provided, return a pattern to match all files for that service
if (termsType === undefined) {
return `${serviceId}/*`;
}

const extension = mime.getExtension(mimeType) || '*'; // If mime type is undefined, an asterisk is set as an extension. Used to match all files for the given service ID, terms type and document ID when mime type is unknown

return `${serviceId}/${generateFileName(termsType, documentId, extension)}`; // Do not use `path.join` as even for Windows, the path should be with `/` and not `\`
Expand Down
8 changes: 6 additions & 2 deletions src/archivist/recorder/repositories/git/git.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,12 @@ export default class Git {
return this.git.push();
}

listCommits(options = []) {
return this.log([ '--reverse', '--no-merges', '--name-only', ...options ]); // Returns all commits in chronological order (`--reverse`), excluding merge commits (`--no-merges`), with modified files names (`--name-only`)
listCommits(options = [], { reverse = true, skip, maxCount } = {}) {
const reverseOption = reverse ? ['--reverse'] : [];
const skipOption = skip !== undefined ? [`--skip=${skip}`] : [];
const maxCountOption = maxCount !== undefined ? [`--max-count=${maxCount}`] : [];

return this.log([ ...reverseOption, '--author-date-order', '--no-merges', '--name-only', ...skipOption, ...maxCountOption, ...options ]); // Returns commits in chronological order with `--reverse` (oldest first) or reverse chronological without it (newest first), sorted by author date (`--author-date-order`), excluding merge commits (`--no-merges`), with modified files names (`--name-only`), with optional pagination (`--skip`, `--max-count`)
}

async getCommit(options) {
Expand Down
78 changes: 67 additions & 11 deletions src/archivist/recorder/repositories/git/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,45 @@ export default class GitRepository extends RepositoryInterface {
return this.#toDomain(commit);
}

async findAll() {
return Promise.all((await this.#getCommits()).map(commit => this.#toDomain(commit, { deferContentLoading: true })));
async findAll({ limit, offset } = {}) {
return Promise.all((await this.#getCommits({ limit, offset })).map(commit => this.#toDomain(commit, { deferContentLoading: true })));
}

async count() {
return (await this.git.log(Object.values(DataMapper.COMMIT_MESSAGE_PREFIXES).map(prefix => `--grep=${prefix}`))).length;
async findByService(serviceId, { limit, offset } = {}) {
const pathPattern = DataMapper.generateFilePath(serviceId);

return Promise.all((await this.#getCommits({ pathFilter: pathPattern, limit, offset })).map(commit => this.#toDomain(commit, { deferContentLoading: true })));
}

async findByServiceAndTermsType(serviceId, termsType, { limit, offset } = {}) {
const pathPattern = DataMapper.generateFilePath(serviceId, termsType);

return Promise.all((await this.#getCommits({ pathFilter: pathPattern, limit, offset })).map(commit => this.#toDomain(commit, { deferContentLoading: true })));
}

async count(serviceId, termsType) {
const grepOptions = Object.values(DataMapper.COMMIT_MESSAGE_PREFIXES).map(prefix => `--grep=${prefix}`);
const pathOptions = [];

if (serviceId && termsType) {
const pathPattern = DataMapper.generateFilePath(serviceId, termsType);

pathOptions.push('--', pathPattern);
} else if (serviceId) {
// Count all records for a service (all terms types)
const pathPattern = DataMapper.generateFilePath(serviceId);

pathOptions.push('--', pathPattern);
} else {
// Count all records (exclude root directory files)
pathOptions.push('--', '*/*');
}

return (await this.git.log([ ...grepOptions, ...pathOptions ])).length;
}

async* iterate() {
const commits = await this.#getCommits();
const commits = await this.#getCommits({ reverse: true });

for (const commit of commits) {
yield this.#toDomain(commit);
Expand Down Expand Up @@ -131,12 +160,39 @@ export default class GitRepository extends RepositoryInterface {
record.content = pdfBuffer;
}

async #getCommits() {
return (await this.git.listCommits())
.filter(commit => // Skip non-record commits (e.g., README or LICENSE updates)
DataMapper.COMMIT_MESSAGE_PREFIXES_REGEXP.test(commit.message) // Commits generated by the engine have messages that match predefined prefixes
&& path.dirname(commit.diff.files[0].file) !== '.') // Assumes one record per commit; records must be in a serviceId folder, not root
.sort((commitA, commitB) => new Date(commitA.date) - new Date(commitB.date)); // Make sure that the commits are sorted in ascending chronological order
async #getCommits({ pathFilter, reverse = false, limit, offset } = {}) {
const grepOptions = Object.values(DataMapper.COMMIT_MESSAGE_PREFIXES).flatMap(prefix => [ '--grep', prefix ]);
const pathOptions = pathFilter
? [ '--', pathFilter ]
: [ '--', '*/*' ]; // Exclude root directory files by only matching files in subdirectories

const options = [ ...grepOptions, ...pathOptions ];

// Use git-level pagination when available
// Note: --skip and --max-count work in topological order, not chronological order
// This means pagination may not be strictly chronological, but it's acceptable for performance
const paginationOptions = {};

if (offset !== undefined) {
paginationOptions.skip = offset;
}

if (limit !== undefined) {
paginationOptions.maxCount = limit;
}

const commits = await this.git.listCommits(options, { reverse: false, ...paginationOptions }); // Get commits without git's --reverse for better performance, filtered at git level

// Sort by date in JavaScript for accuracy - git's date ordering may not be reliable with backdated commits
// Default order is descending (newest to oldest), reverse gives ascending (oldest to newest)
commits.sort((commitA, commitB) => {
const dateA = new Date(commitA.date);
const dateB = new Date(commitB.date);

return reverse ? dateA - dateB : dateB - dateA;
});

return commits;
}

static async writeFile({ filePath, content }) {
Expand Down
Loading
Loading