forked from ryanmwangi/CalMerger
Compare commits
No commits in common. "30622c611a2850ed1146592ec1a04e30f9655824" and "869beeb3d46715bca6686835b173b8f3bb57ef87" have entirely different histories.
30622c611a
...
869beeb3d4
12 changed files with 93 additions and 367 deletions
Binary file not shown.
17
README.md
17
README.md
|
@ -18,17 +18,6 @@ The application also generates a unique URL for the merged calendar and updates
|
|||
- Generate a unique URL for the merged calendar
|
||||
- Automatically update the merged calendar every hour
|
||||
|
||||
## Calender Directory
|
||||
|
||||
#### `calendar/` (MERGED_CALENDARS_DIR)
|
||||
- Location: Created in application's current working directory
|
||||
- Purpose: Stores all generated calendar files
|
||||
- Contains:
|
||||
- `.ics` files - Final merged calendars in iCalendar format
|
||||
- `.json` files - Configuration preserving original merge parameters
|
||||
- Maintains both formats for each merged calendar group
|
||||
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Node.js](https://nodejs.org/) (version 14 or higher)
|
||||
|
@ -65,11 +54,11 @@ docker build -t calmerger-app .
|
|||
To start the container, use:
|
||||
|
||||
```bash
|
||||
docker run -d --name calmerger -p 3012:3012 calmerger-app
|
||||
docker run -d --name calmerger -p 3000:3000 calmerger-app
|
||||
|
||||
```
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
### 3. Using Docker Compose (Optional)
|
||||
|
||||
|
@ -125,7 +114,7 @@ This generates a `coverage` report, showing how much of the codebase is tested.
|
|||
|
||||
## Usage
|
||||
|
||||
1. Open a web browser and navigate to `http://localhost:3012`.
|
||||
1. Open a web browser and navigate to `http://localhost:3000`.
|
||||
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).
|
||||
4. Click the **Merge Calendars** button to generate the merged calendar.
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
version: "3.3"
|
||||
|
||||
services:
|
||||
calmerge:
|
||||
build: .
|
||||
ports:
|
||||
- "3012:3012"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NODE_PORT=3012
|
||||
volumes:
|
||||
- ./calendar:/app/calendar
|
||||
- ./logs:/app/logs
|
||||
restart: unless-stopped
|
|
@ -8,13 +8,13 @@ WORKDIR /usr/src/app
|
|||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install --omit=dev
|
||||
RUN npm install --production
|
||||
|
||||
# Copy the rest of the project files
|
||||
COPY . .
|
||||
|
||||
# Expose the port your application runs on (if applicable)
|
||||
EXPOSE 3012
|
||||
EXPOSE 3000
|
||||
|
||||
# Command to run the application
|
||||
CMD ["node", "src/app.js"]
|
||||
|
|
39
package-lock.json
generated
39
package-lock.json
generated
|
@ -22,7 +22,6 @@
|
|||
"@babel/plugin-transform-modules-commonjs": "^7.25.9",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/register": "^7.25.9",
|
||||
"axios-mock-adapter": "^2.1.0",
|
||||
"babel-jest": "^29.7.0",
|
||||
"jest": "^29.7.0",
|
||||
"rewire": "^7.0.0"
|
||||
|
@ -2680,20 +2679,6 @@
|
|||
"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": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
||||
|
@ -4517,30 +4502,6 @@
|
|||
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
|
||||
"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": {
|
||||
"version": "2.15.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz",
|
||||
|
|
|
@ -24,7 +24,6 @@
|
|||
"@babel/plugin-transform-modules-commonjs": "^7.25.9",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/register": "^7.25.9",
|
||||
"axios-mock-adapter": "^2.1.0",
|
||||
"babel-jest": "^29.7.0",
|
||||
"jest": "^29.7.0",
|
||||
"rewire": "^7.0.0"
|
||||
|
|
|
@ -4,47 +4,45 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Calendar Merger</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
#calendars {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
#calendars .calendar {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
#calendars .calendar input[type="text"] {
|
||||
width: 50%;
|
||||
margin-right: 10px;
|
||||
}
|
||||
#calendars .calendar input[type="url"] {
|
||||
width: 50%;
|
||||
}
|
||||
#add-calendar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>📅 Calendar Merger</h1>
|
||||
|
||||
<div class="form-card">
|
||||
<form id="merge-form">
|
||||
<div class="input-group">
|
||||
<input type="text"
|
||||
id="link-group-name"
|
||||
placeholder="Enter collection name"
|
||||
class="input-field">
|
||||
</div>
|
||||
|
||||
<div id="calendars">
|
||||
<div class="calendar-entry">
|
||||
<input type="url" id="url-0" placeholder="https://example.com/calendar.ics">
|
||||
<input type="text" id="prefix-0" placeholder="Event prefix">
|
||||
<div class="checkbox-group">
|
||||
<h1>Calendar Merger</h1>
|
||||
<form id="merge-form">
|
||||
<input type="text" id="link-group-name" placeholder="Link Group Name">
|
||||
<div id="calendars">
|
||||
<div class="calendar">
|
||||
<input type="text" id="prefix-0" placeholder="Prefix">
|
||||
<input type="checkbox" id="override-0">
|
||||
<label for="override-0">Override</label>
|
||||
</div>
|
||||
<button type="button" class="remove-btn" title="Remove calendar"></button>
|
||||
<input type="url" id="url-0" placeholder="Calendar URL">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button type="button" id="add-calendar" class="button secondary-btn">
|
||||
➕ Add Another Calendar
|
||||
</button>
|
||||
<button type="submit" class="button primary-btn">
|
||||
🔗 Merge Calendars
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="result"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="add-calendar" type="button">Add Calendar</button>
|
||||
<button type="submit">Merge Calendars</button>
|
||||
</form>
|
||||
<div id="result"></div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
|
@ -14,15 +14,12 @@ function isValidUrl(url) {
|
|||
|
||||
addCalendarButton.addEventListener('click', () => {
|
||||
const newCalendar = document.createElement('div');
|
||||
newCalendar.className = 'calendar-entry';
|
||||
newCalendar.className = 'calendar';
|
||||
newCalendar.innerHTML = `
|
||||
<input type="url" id="url-${calendarIndex}" placeholder="https://example.com/calendar.ics">
|
||||
<input type="text" id="prefix-${calendarIndex}" placeholder="Event prefix">
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="override-${calendarIndex}">
|
||||
<label for="override-${calendarIndex}">Override</label>
|
||||
</div>
|
||||
<button type="button" class="remove-btn" title="Remove calendar"></button>
|
||||
<input type="text" id="prefix-${calendarIndex}" placeholder="Prefix">
|
||||
<input type="checkbox" id="override-${calendarIndex}">
|
||||
<label for="override-${calendarIndex}">Override</label>
|
||||
<input type="url" id="url-${calendarIndex}" placeholder="Calendar URL">
|
||||
`;
|
||||
calendars.appendChild(newCalendar);
|
||||
calendarIndex++;
|
||||
|
@ -78,12 +75,3 @@ function isValidUrl(url) {
|
|||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (event.target.classList.contains('remove-btn')) {
|
||||
const calendarEntry = event.target.closest('.calendar-entry');
|
||||
if (calendarEntry) {
|
||||
calendarEntry.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,159 +1,37 @@
|
|||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--background: #f8fafc;
|
||||
--surface: #ffffff;
|
||||
--border: #e2e8f0;
|
||||
--text: #1e293b;
|
||||
}
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
background: var(--background);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
padding: 2rem;
|
||||
}
|
||||
#merge-form {
|
||||
max-width: 400px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Form Styling */
|
||||
.form-card {
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Calendar Entry Styling */
|
||||
.calendar-entry {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: var(--background);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.calendar-entry:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Input Elements */
|
||||
input[type="text"],
|
||||
input[type="url"] {
|
||||
padding: 0.75rem;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 6px;
|
||||
input[type="url"], input[type="text"] {
|
||||
width: 100%;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
/* Checkbox Styling */
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Button Styling */
|
||||
.button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
button[type="submit"] {
|
||||
background-color: #4CAF50;
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: var(--background);
|
||||
border: 2px solid var(--border);
|
||||
}
|
||||
|
||||
/* Result Display */
|
||||
#result {
|
||||
padding: 1.5rem;
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
margin-top: 1rem;
|
||||
border: 2px dashed var(--border);
|
||||
}
|
||||
|
||||
#result a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 640px) {
|
||||
.calendar-entry {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
background: #fee2e2;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.remove-btn::before {
|
||||
content: '×';
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
}
|
||||
button[type="submit"]:hover {
|
||||
background-color: #3e8e41;
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import server from './server.js';
|
||||
|
||||
const port = process.env.NODE_PORT || 3012;
|
||||
const port = process.env.NODE_PORT || 3000;
|
||||
server.listen(port, () => {
|
||||
console.log(`Server started on port ${port}`);
|
||||
});
|
||||
|
|
|
@ -15,33 +15,18 @@ export const sanitizeFilename = (filename) => filename.replace(/[<>:"/\\|?* ]/g,
|
|||
// Fetch calendar data from URL or file
|
||||
export async function fetchCalendarData(calendar) {
|
||||
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 {
|
||||
// First try the original URL
|
||||
const initialResponse = await axios.get(calendar.url);
|
||||
return { data: initialResponse.data, ...calendar };
|
||||
} catch (initialError) {
|
||||
logger.debug(`Initial fetch failed, trying extension adjustment for: ${calendar.url}`);
|
||||
|
||||
// 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`);
|
||||
if (isFilePath) {
|
||||
// logger.debug(`Reading calendar from file: ${calendar.url}`);
|
||||
return { data: fs.readFileSync(path.resolve(calendar.url), 'utf-8'), ...calendar };
|
||||
} else {
|
||||
// logger.debug(`Fetching calendar from URL: ${calendar.url}`);
|
||||
const response = await axios.get(calendar.url);
|
||||
return { data: response.data, ...calendar };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error retrieving calendar from ${calendar.url}: ${error.message}`);
|
||||
throw new Error(`Error retrieving calendar from ${calendar.url}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,15 +2,12 @@ import request from 'supertest';
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import axios from 'axios';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { fetchCalendarData } from '../src/calendarUtil.js';
|
||||
|
||||
// ESM equivalent of __dirname
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const CALENDARS_DIR = path.join(process.cwd(), 'calendar');
|
||||
const CALENDARS_DIR = path.join(__dirname, 'calendar');
|
||||
const TEST_CALENDARS_DIR = path.join(__dirname, 'test_calendars');
|
||||
const EXPECTED_OUTPUTS_DIR = path.join(__dirname, 'expected_outputs');
|
||||
|
||||
|
@ -29,8 +26,9 @@ describe('Calendar Merging API', () => {
|
|||
await new Promise(resolve => server.close(resolve));
|
||||
|
||||
// Clean up the merged calendars directory after tests
|
||||
fs.rmdirSync(CALENDARS_DIR, { recursive: true });
|
||||
if (fs.existsSync(CALENDARS_DIR)) {
|
||||
fs.rmSync(CALENDARS_DIR, { recursive: true, force: true });
|
||||
fs.rmdirSync(CALENDARS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Optional: Add a delay to ensure all handles are released
|
||||
|
@ -282,60 +280,4 @@ describe('Calendar Merging API', () => {
|
|||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue