Compare commits

...

3 commits

9 changed files with 135 additions and 22 deletions

BIN
.qodo/history.sqlite Normal file

Binary file not shown.

View file

@ -65,11 +65,11 @@ docker build -t calmerger-app .
To start the container, use: To start the container, use:
```bash ```bash
docker run -d --name calmerger -p 3000:3000 calmerger-app docker run -d --name calmerger -p 3012:3012 calmerger-app
``` ```
This maps the container's port `3000` to the host system's port `3000`. The application will be accessible at [http://localhost:3000](http://localhost:3000). This maps the container's port `3012` to the host system's port `3012`. The application will be accessible at [http://localhost:3012](http://localhost:3012).
### 3. Using Docker Compose (Optional) ### 3. Using Docker Compose (Optional)
@ -125,7 +125,7 @@ This generates a `coverage` report, showing how much of the codebase is tested.
## Usage ## Usage
1. Open a web browser and navigate to `http://localhost:3000`. 1. Open a web browser and navigate to `http://localhost:3012`.
2. Click the **Add Calendar** button to add a new calendar. 2. Click the **Add Calendar** button to add a new calendar.
3. Enter the Link Group Name, calendar URL, prefix, and override options (if needed). 3. Enter the Link Group Name, calendar URL, prefix, and override options (if needed).
4. Click the **Merge Calendars** button to generate the merged calendar. 4. Click the **Merge Calendars** button to generate the merged calendar.

View file

@ -1,13 +1,13 @@
version: '3.3' version: "3.3"
services: services:
calmerge: calmerge:
build: . build: .
ports: ports:
- "3000:3000" - "3012:3012"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- NODE_PORT=3000 - NODE_PORT=3012
volumes: volumes:
- ./calendar:/app/calendar - ./calendar:/app/calendar
- ./logs:/app/logs - ./logs:/app/logs

View file

@ -14,7 +14,7 @@ RUN npm install --omit=dev
COPY . . COPY . .
# Expose the port your application runs on (if applicable) # Expose the port your application runs on (if applicable)
EXPOSE 3000 EXPOSE 3012
# Command to run the application # Command to run the application
CMD ["node", "src/app.js"] CMD ["node", "src/app.js"]

39
package-lock.json generated
View file

@ -22,6 +22,7 @@
"@babel/plugin-transform-modules-commonjs": "^7.25.9", "@babel/plugin-transform-modules-commonjs": "^7.25.9",
"@babel/preset-env": "^7.26.0", "@babel/preset-env": "^7.26.0",
"@babel/register": "^7.25.9", "@babel/register": "^7.25.9",
"axios-mock-adapter": "^2.1.0",
"babel-jest": "^29.7.0", "babel-jest": "^29.7.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"rewire": "^7.0.0" "rewire": "^7.0.0"
@ -2679,6 +2680,20 @@
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
"node_modules/axios-mock-adapter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-2.1.0.tgz",
"integrity": "sha512-AZUe4OjECGCNNssH8SOdtneiQELsqTsat3SQQCWLPjN436/H+L9AjWfV7bF+Zg/YL9cgbhrz5671hoh+Tbn98w==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"is-buffer": "^2.0.5"
},
"peerDependencies": {
"axios": ">= 0.17.0"
}
},
"node_modules/babel-jest": { "node_modules/babel-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@ -4502,6 +4517,30 @@
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"dev": true "dev": true
}, },
"node_modules/is-buffer": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/is-core-module": { "node_modules/is-core-module": {
"version": "2.15.1", "version": "2.15.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz",

View file

@ -24,6 +24,7 @@
"@babel/plugin-transform-modules-commonjs": "^7.25.9", "@babel/plugin-transform-modules-commonjs": "^7.25.9",
"@babel/preset-env": "^7.26.0", "@babel/preset-env": "^7.26.0",
"@babel/register": "^7.25.9", "@babel/register": "^7.25.9",
"axios-mock-adapter": "^2.1.0",
"babel-jest": "^29.7.0", "babel-jest": "^29.7.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"rewire": "^7.0.0" "rewire": "^7.0.0"

View file

@ -1,6 +1,6 @@
import server from './server.js'; import server from './server.js';
const port = process.env.NODE_PORT || 3000; const port = process.env.NODE_PORT || 3012;
server.listen(port, () => { server.listen(port, () => {
console.log(`Server started on port ${port}`); console.log(`Server started on port ${port}`);
}); });

View file

@ -15,18 +15,33 @@ export const sanitizeFilename = (filename) => filename.replace(/[<>:"/\\|?* ]/g,
// Fetch calendar data from URL or file // Fetch calendar data from URL or file
export async function fetchCalendarData(calendar) { export async function fetchCalendarData(calendar) {
const isFilePath = !calendar.url.startsWith('http'); const isFilePath = !calendar.url.startsWith('http');
if (isFilePath) {
// logger.debug(`Reading calendar from file: ${calendar.url}`);
return { data: fs.readFileSync(path.resolve(calendar.url), 'utf-8'), ...calendar };
}
try { try {
if (isFilePath) { // First try the original URL
// logger.debug(`Reading calendar from file: ${calendar.url}`); const initialResponse = await axios.get(calendar.url);
return { data: fs.readFileSync(path.resolve(calendar.url), 'utf-8'), ...calendar }; return { data: initialResponse.data, ...calendar };
} else { } catch (initialError) {
// logger.debug(`Fetching calendar from URL: ${calendar.url}`); logger.debug(`Initial fetch failed, trying extension adjustment for: ${calendar.url}`);
const response = await axios.get(calendar.url);
return { data: response.data, ...calendar }; // Determine alternate URL version
const altUrl = calendar.url.endsWith('.ics')
? calendar.url.slice(0, -4) // Remove .ics
: calendar.url + '.ics'; // Add .ics
try {
// Try the alternate version
const altResponse = await axios.get(altUrl);
logger.debug(`Success with adjusted URL: ${altUrl}`);
return { data: altResponse.data, ...calendar };
} catch (altError) {
logger.error(`Both URL versions failed:
Original: ${calendar.url}
Adjusted: ${altUrl}`);
throw new Error(`Calendar fetch failed for both URL versions`);
} }
} catch (error) {
logger.error(`Error retrieving calendar from ${calendar.url}: ${error.message}`);
throw new Error(`Error retrieving calendar from ${calendar.url}: ${error.message}`);
} }
} }

View file

@ -2,12 +2,15 @@ import request from 'supertest';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { fetchCalendarData } from '../src/calendarUtil.js';
// ESM equivalent of __dirname // ESM equivalent of __dirname
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const CALENDARS_DIR = path.join(__dirname, 'calendar'); const CALENDARS_DIR = path.join(process.cwd(), 'calendar');
const TEST_CALENDARS_DIR = path.join(__dirname, 'test_calendars'); const TEST_CALENDARS_DIR = path.join(__dirname, 'test_calendars');
const EXPECTED_OUTPUTS_DIR = path.join(__dirname, 'expected_outputs'); const EXPECTED_OUTPUTS_DIR = path.join(__dirname, 'expected_outputs');
@ -26,9 +29,8 @@ describe('Calendar Merging API', () => {
await new Promise(resolve => server.close(resolve)); await new Promise(resolve => server.close(resolve));
// Clean up the merged calendars directory after tests // Clean up the merged calendars directory after tests
fs.rmdirSync(CALENDARS_DIR, { recursive: true });
if (fs.existsSync(CALENDARS_DIR)) { if (fs.existsSync(CALENDARS_DIR)) {
fs.rmdirSync(CALENDARS_DIR, { recursive: true }); fs.rmSync(CALENDARS_DIR, { recursive: true, force: true });
} }
// Optional: Add a delay to ensure all handles are released // Optional: Add a delay to ensure all handles are released
@ -280,4 +282,60 @@ describe('Calendar Merging API', () => {
expect(actualOutput).toBe(expectedOutput); expect(actualOutput).toBe(expectedOutput);
}); });
describe('Smart URL Handling', () => {
let mockAxios;
beforeAll(() => {
mockAxios = new MockAdapter(axios);
});
afterEach(() => {
mockAxios.reset();
});
afterAll(() => {
mockAxios.restore();
});
test('should use original URL when valid without .ics', async () => {
const validUrl = 'https://cals.ftt.gmbh/calendar/germanholithunder';
mockAxios.onGet(validUrl).reply(200, 'VALID_CALENDAR');
const result = await fetchCalendarData({ url: validUrl });
expect(result.data).toBe('VALID_CALENDAR');
});
test('should try .ics version when original fails', async () => {
const invalidUrl = 'https://cals.ftt.gmbh/calendar/germanholithunder.ics';
const validUrl = invalidUrl.slice(0, -4);
mockAxios
.onGet(invalidUrl).reply(404)
.onGet(validUrl).reply(200, 'VALID_CALENDAR');
const result = await fetchCalendarData({ url: invalidUrl });
expect(result.data).toBe('VALID_CALENDAR');
});
test('should preserve valid .ics URLs', async () => {
const googleUrl = 'https://calendar.google.com/.../basic.ics';
mockAxios.onGet(googleUrl).reply(200, 'GOOGLE_CALENDAR');
const result = await fetchCalendarData({ url: googleUrl });
expect(result.data).toBe('GOOGLE_CALENDAR');
});
test('should try both versions for ambiguous URLs', async () => {
const baseUrl = 'https://example.com/calendar';
const icsUrl = baseUrl + '.ics';
mockAxios
.onGet(baseUrl).reply(404)
.onGet(icsUrl).reply(200, 'ICS_CALENDAR');
const result = await fetchCalendarData({ url: baseUrl });
expect(result.data).toBe('ICS_CALENDAR');
});
});
}); });