nocalendar 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # Nocal
2
+
3
+ A lightweight nodejs calendar library.
4
+
5
+ ## Features
6
+
7
+ - Create, read, update, and delete calendar events
8
+ - SQLite database persistence
9
+ - ICS (iCalendar) export and import
10
+ - RESTful API server
11
+ - Basic and Bearer authentication support
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install nocal2
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```javascript
22
+ const nocal = require('nocal2');
23
+
24
+ const cal = nocal.load('./calendar.db');
25
+
26
+ // Create an event
27
+ const eventId = await cal.createEvent({
28
+ title: 'My Event',
29
+ description: 'Description',
30
+ startDate: '2023-10-01T10:00:00Z',
31
+ endDate: '2023-10-01T11:00:00Z',
32
+ rrule: 'FREQ=WEEKLY;BYDAY=MO'
33
+ });
34
+
35
+ // List events
36
+ const events = await cal.listEvents();
37
+
38
+ // Start API server
39
+ const server = await cal.start(3000);
40
+ ```
41
+
42
+ ## API
43
+
44
+ ### Methods
45
+
46
+ - `load(dbPath)`: Load or create a calendar database
47
+ - `createEvent(eventData)`: Create a new event
48
+ - `listEvents()`: Get all events
49
+ - `updateEvent(id, updates)`: Update an event
50
+ - `removeEvent(id)`: Delete an event
51
+ - `exportIcs()`: Export events as ICS
52
+ - `importIcs(icsContent)`: Import events from ICS
53
+ - `start(port, callback)`: Start REST API server
54
+
55
+ ## API Endpoints
56
+
57
+ - `GET /events`: List all events
58
+ - `POST /events`: Create a new event
59
+ - `PUT /events/:id`: Update an event
60
+ - `DELETE /events/:id`: Delete an event
61
+ - `GET /export`: Export as ICS
62
+
63
+ ## License
64
+
65
+ MIT
package/index.js ADDED
@@ -0,0 +1,282 @@
1
+ const Database = require('./src/database');
2
+ const { v4: uuidv4, validate: uuidValidate } = require('uuid');
3
+ const ICAL = require('ical.js');
4
+ const bcrypt = require('bcrypt');
5
+
6
+ class Nocal {
7
+ constructor(dbPath) {
8
+ this.db = new Database(dbPath);
9
+ }
10
+
11
+ static load(dbPath) {
12
+ return new Nocal(dbPath);
13
+ }
14
+
15
+ async createEvent(eventData) {
16
+ const event = {
17
+ id: uuidv4(),
18
+ title: eventData.title,
19
+ description: eventData.description || '',
20
+ startDate: eventData.startDate,
21
+ endDate: eventData.endDate,
22
+ rrule: eventData.rrule || null
23
+ };
24
+ await this.db.createEvent(event);
25
+ return event.id;
26
+ }
27
+
28
+ async listEvents() {
29
+ const events = await this.db.getEvents();
30
+ // For now, return raw events. Later expand recurring.
31
+ return events;
32
+ }
33
+
34
+ async removeEvent(eventId) {
35
+ const result = await this.db.deleteEvent(eventId);
36
+ return result.changes > 0;
37
+ }
38
+
39
+ async updateEvent(eventId, updates) {
40
+ const result = await this.db.updateEvent(eventId, updates);
41
+ return result.changes > 0;
42
+ }
43
+
44
+ async exportIcs() {
45
+ const events = await this.listEvents();
46
+ const cal = new ICAL.Component(['vcalendar', [], []]);
47
+ cal.updatePropertyWithValue('version', '2.0');
48
+ cal.updatePropertyWithValue('prodid', '-//Nocal//Calendar//EN');
49
+
50
+ events.forEach(event => {
51
+ const vevent = new ICAL.Component('vevent');
52
+ vevent.updatePropertyWithValue('uid', event.id);
53
+ vevent.updatePropertyWithValue('summary', event.title);
54
+ if (event.description) vevent.updatePropertyWithValue('description', event.description);
55
+ vevent.updatePropertyWithValue('dtstart', ICAL.Time.fromDateTimeString(event.start_date));
56
+ vevent.updatePropertyWithValue('dtend', ICAL.Time.fromDateTimeString(event.end_date));
57
+ if (event.rrule) {
58
+ // Parse RRULE
59
+ const rrule = ICAL.Recur.fromString(event.rrule);
60
+ vevent.updatePropertyWithValue('rrule', rrule);
61
+ }
62
+ cal.addSubcomponent(vevent);
63
+ });
64
+
65
+ return cal.toString();
66
+ }
67
+
68
+ async importIcs(content) {
69
+ const jcal = ICAL.parse(content);
70
+ const cal = new ICAL.Component(jcal);
71
+ const vevents = cal.getAllSubcomponents('vevent');
72
+
73
+ for (const vevent of vevents) {
74
+ const uid = vevent.getFirstPropertyValue('uid');
75
+ const summary = vevent.getFirstPropertyValue('summary');
76
+ const description = vevent.getFirstPropertyValue('description');
77
+ const dtstart = vevent.getFirstPropertyValue('dtstart');
78
+ const dtend = vevent.getFirstPropertyValue('dtend');
79
+ const rrule = vevent.getFirstPropertyValue('rrule');
80
+
81
+ const eventData = {
82
+ title: summary,
83
+ description: description || '',
84
+ startDate: dtstart.toString(),
85
+ endDate: dtend.toString(),
86
+ rrule: rrule ? rrule.toString() : null
87
+ };
88
+
89
+ await this.createEvent(eventData);
90
+ }
91
+ }
92
+
93
+ async start(port = 3000, hostOrCallback = 'localhost', optionsOrCallback = null) {
94
+ let host = 'localhost';
95
+ let options = {};
96
+ let callback = null;
97
+
98
+ if (typeof hostOrCallback === 'function') {
99
+ callback = hostOrCallback;
100
+ } else if (typeof hostOrCallback === 'string') {
101
+ host = hostOrCallback;
102
+ } else if (typeof hostOrCallback === 'object') {
103
+ options = hostOrCallback;
104
+ }
105
+
106
+ if (typeof optionsOrCallback === 'function') {
107
+ callback = optionsOrCallback;
108
+ } else if (typeof optionsOrCallback === 'object' && optionsOrCallback !== null) {
109
+ options = optionsOrCallback;
110
+ }
111
+
112
+ // Hash password for basic auth
113
+ if (options.auth && options.auth.type === 'basic') {
114
+ if (!options.auth.password.startsWith('$2')) {
115
+ options.auth.hashedPassword = await bcrypt.hash(options.auth.password, 10);
116
+ } else {
117
+ options.auth.hashedPassword = options.auth.password;
118
+ }
119
+ }
120
+
121
+ // Auth options: { auth: { type: 'basic', username: 'user', password: 'pass' } } or { type: 'bearer', token: 'token' }
122
+ const http = require('http');
123
+ const https = require('https');
124
+ const MAX_PAYLOAD_SIZE = 10 * 1024; // 10kb
125
+
126
+ const serverModule = options.ssl ? https : http;
127
+ const server = serverModule.createServer(options.ssl || {}, async (req, res) => {
128
+ // Auth check
129
+ if (options.auth) {
130
+ const authHeader = req.headers.authorization;
131
+ let authorized = false;
132
+
133
+ if (options.auth.type === 'basic') {
134
+ if (authHeader && authHeader.startsWith('Basic ')) {
135
+ const base64Credentials = authHeader.split(' ')[1];
136
+ const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii');
137
+ const [username, password] = credentials.split(':');
138
+ if (username === options.auth.username && await bcrypt.compare(password, options.auth.hashedPassword)) {
139
+ authorized = true;
140
+ }
141
+ }
142
+ } else if (options.auth.type === 'bearer') {
143
+ if (authHeader && authHeader.startsWith('Bearer ') && authHeader.split(' ')[1] === options.auth.token) {
144
+ authorized = true;
145
+ }
146
+ }
147
+
148
+ if (!authorized) {
149
+ res.writeHead(401, { 'WWW-Authenticate': options.auth.type === 'basic' ? 'Basic realm="Nocal API"' : 'Bearer' });
150
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
151
+ return;
152
+ }
153
+ }
154
+
155
+ const parsedUrl = new URL(req.url, 'http://dummy');
156
+ const method = req.method;
157
+ const path = parsedUrl.pathname;
158
+
159
+ res.setHeader('Content-Type', 'application/json');
160
+
161
+ // Validate event ID for routes that use it
162
+ let eventId = null;
163
+ if (path.startsWith('/events/')) {
164
+ const parts = path.split('/');
165
+ if (parts.length === 3 && parts[1] === 'events') {
166
+ eventId = parts[2];
167
+ if (!eventId || !uuidValidate(eventId)) {
168
+ res.writeHead(400);
169
+ res.end(JSON.stringify({ error: 'Invalid event ID' }));
170
+ return;
171
+ }
172
+ }
173
+ }
174
+
175
+ if (path === '/events' && method === 'GET') {
176
+ try {
177
+ const events = await this.listEvents();
178
+ res.writeHead(200);
179
+ res.end(JSON.stringify(events));
180
+ } catch (err) {
181
+ res.writeHead(500);
182
+ res.end(JSON.stringify({ error: 'Internal server error' }));
183
+ }
184
+ } else if (path === '/events' && method === 'POST') {
185
+ let body = '';
186
+ let bodySize = 0;
187
+ req.on('data', chunk => {
188
+ bodySize += chunk.length;
189
+ if (bodySize > MAX_PAYLOAD_SIZE) {
190
+ res.writeHead(413);
191
+ res.end(JSON.stringify({ error: 'Payload too large' }));
192
+ req.destroy();
193
+ return;
194
+ }
195
+ body += chunk;
196
+ });
197
+ req.on('end', async () => {
198
+ try {
199
+ const eventData = JSON.parse(body);
200
+ if (!eventData.title || !eventData.startDate || !eventData.endDate) {
201
+ res.writeHead(400);
202
+ res.end(JSON.stringify({ error: 'Missing required fields: title, startDate, endDate' }));
203
+ return;
204
+ }
205
+ const id = await this.createEvent(eventData);
206
+ res.writeHead(201);
207
+ res.end(JSON.stringify({ id }));
208
+ } catch (err) {
209
+ res.writeHead(400);
210
+ res.end(JSON.stringify({ error: 'Invalid JSON or data' }));
211
+ }
212
+ });
213
+ } else if (path.startsWith('/events/') && method === 'PUT') {
214
+ let body = '';
215
+ let bodySize = 0;
216
+ req.on('data', chunk => {
217
+ bodySize += chunk.length;
218
+ if (bodySize > MAX_PAYLOAD_SIZE) {
219
+ res.writeHead(413);
220
+ res.end(JSON.stringify({ error: 'Payload too large' }));
221
+ req.destroy();
222
+ return;
223
+ }
224
+ body += chunk;
225
+ });
226
+ req.on('end', async () => {
227
+ try {
228
+ const updates = JSON.parse(body);
229
+ delete updates.id; // Prevent updating id
230
+ const success = await this.updateEvent(eventId, updates);
231
+ if (success) {
232
+ res.writeHead(200);
233
+ res.end(JSON.stringify({ message: 'Updated' }));
234
+ } else {
235
+ res.writeHead(404);
236
+ res.end(JSON.stringify({ error: 'Event not found' }));
237
+ }
238
+ } catch (err) {
239
+ res.writeHead(400);
240
+ res.end(JSON.stringify({ error: 'Invalid JSON or data' }));
241
+ }
242
+ });
243
+ } else if (path.startsWith('/events/') && method === 'DELETE') {
244
+ try {
245
+ const success = await this.removeEvent(eventId);
246
+ if (success) {
247
+ res.writeHead(200);
248
+ res.end(JSON.stringify({ message: 'Deleted' }));
249
+ } else {
250
+ res.writeHead(404);
251
+ res.end(JSON.stringify({ error: 'Event not found' }));
252
+ }
253
+ } catch (err) {
254
+ res.writeHead(500);
255
+ res.end(JSON.stringify({ error: 'Internal server error' }));
256
+ }
257
+ } else if (path === '/export' && method === 'GET') {
258
+ try {
259
+ const ics = await this.exportIcs();
260
+ res.setHeader('Content-Type', 'text/calendar');
261
+ res.writeHead(200);
262
+ res.end(ics);
263
+ } catch (err) {
264
+ res.writeHead(500);
265
+ res.end(JSON.stringify({ error: 'Internal server error' }));
266
+ }
267
+ } else {
268
+ res.writeHead(404);
269
+ res.end(JSON.stringify({ error: 'Not found' }));
270
+ }
271
+ });
272
+
273
+ server.listen(port, host, () => {
274
+ console.log(`Nocal API listening on http://${host}:${port}`);
275
+ if (callback) callback();
276
+ });
277
+
278
+ return server;
279
+ }
280
+ }
281
+
282
+ module.exports = { load: Nocal.load };
Binary file
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "nocalendar",
3
+ "version": "0.2.0",
4
+ "description": "A lightweight nodejs calendar library. ",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "node test.js"
8
+ },
9
+ "keywords": [
10
+ "calendar",
11
+ "ics",
12
+ "sqlite",
13
+ "nodejs",
14
+ "nocal",
15
+ "nocal2"
16
+ ],
17
+ "author": "little codex",
18
+ "license": "MIT",
19
+ "dependencies": {
20
+ "ical.js": "^1.5.0",
21
+ "uuid": "^9.0.1",
22
+ "bcrypt": "^5.1.1"
23
+ }
24
+ }
@@ -0,0 +1,64 @@
1
+ const { DatabaseSync } = require('node:sqlite');
2
+
3
+ class Database {
4
+ constructor(dbPath) {
5
+ this.db = new DatabaseSync(dbPath);
6
+ this.init();
7
+ }
8
+
9
+ init() {
10
+ const sql = `
11
+ CREATE TABLE IF NOT EXISTS events (
12
+ id TEXT PRIMARY KEY,
13
+ title TEXT NOT NULL,
14
+ description TEXT,
15
+ start_date TEXT NOT NULL,
16
+ end_date TEXT NOT NULL,
17
+ rrule TEXT,
18
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
19
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
20
+ );
21
+ `;
22
+ this.db.exec(sql);
23
+ }
24
+
25
+ // Methods for CRUD
26
+ createEvent(event) {
27
+ const sql = `
28
+ INSERT INTO events (id, title, description, start_date, end_date, rrule)
29
+ VALUES (?, ?, ?, ?, ?, ?)
30
+ `;
31
+ const stmt = this.db.prepare(sql);
32
+ stmt.run(event.id, event.title, event.description, event.startDate, event.endDate, event.rrule);
33
+ return { id: event.id };
34
+ }
35
+
36
+ getEvents() {
37
+ const sql = 'SELECT * FROM events';
38
+ const stmt = this.db.prepare(sql);
39
+ return stmt.all();
40
+ }
41
+
42
+ updateEvent(id, updates) {
43
+ const fields = Object.keys(updates).map(key => `${key} = ?`).join(', ');
44
+ const values = Object.values(updates);
45
+ values.push(id);
46
+ const sql = `UPDATE events SET ${fields}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`;
47
+ const stmt = this.db.prepare(sql);
48
+ const result = stmt.run(...values);
49
+ return { changes: result.changes };
50
+ }
51
+
52
+ deleteEvent(id) {
53
+ const sql = 'DELETE FROM events WHERE id = ?';
54
+ const stmt = this.db.prepare(sql);
55
+ const result = stmt.run(id);
56
+ return { changes: result.changes };
57
+ }
58
+
59
+ close() {
60
+ this.db.close();
61
+ }
62
+ }
63
+
64
+ module.exports = Database;
package/test.js ADDED
@@ -0,0 +1,138 @@
1
+ const nocal = require('./index');
2
+
3
+ async function test() {
4
+ const cal = nocal.load('./test.db');
5
+
6
+ console.log('Testing createEvent...');
7
+ const id = await cal.createEvent({
8
+ title: 'My Event',
9
+ description: 'My personal event',
10
+ startDate: '2023-10-01T10:00:00Z',
11
+ endDate: '2023-10-01T11:00:00Z',
12
+ rrule: 'FREQ=WEEKLY;BYDAY=MO'
13
+ });
14
+ console.log('Created event:', id);
15
+
16
+ console.log('Testing listEvents...');
17
+ let events = await cal.listEvents();
18
+ console.log('Events count:', events.length);
19
+
20
+ console.log('Testing updateEvent...');
21
+ const updated = await cal.updateEvent(id, { title: 'Updated Event', description: 'Updated description' });
22
+ console.log('Update result:', updated);
23
+
24
+ console.log('Testing listEvents after update...');
25
+ events = await cal.listEvents();
26
+ console.log('First event title:', events[0].title);
27
+
28
+ console.log('Testing exportIcs...');
29
+ const ics = await cal.exportIcs();
30
+ console.log('ICS exported, length:', ics.length);
31
+
32
+ console.log('Testing importIcs...');
33
+ // Create a simple ICS content for import
34
+ const icsImport = `BEGIN:VCALENDAR
35
+ VERSION:2.0
36
+ BEGIN:VEVENT
37
+ UID:test-import-123
38
+ SUMMARY:Imported Event
39
+ DESCRIPTION:Test import
40
+ DTSTART:20231002T120000Z
41
+ DTEND:20231002T130000Z
42
+ END:VEVENT
43
+ END:VCALENDAR`;
44
+ await cal.importIcs(icsImport);
45
+ console.log('ICS imported');
46
+
47
+ console.log('Testing listEvents after import...');
48
+ events = await cal.listEvents();
49
+ console.log('Events count after import:', events.length);
50
+
51
+ console.log('Testing removeEvent...');
52
+ const removed = await cal.removeEvent(id);
53
+ console.log('Remove result:', removed);
54
+
55
+ console.log('Testing listEvents after remove...');
56
+ events = await cal.listEvents();
57
+ console.log('Events count after remove:', events.length);
58
+
59
+ console.log('Testing start API...');
60
+ const server = await cal.start(3000, () => {
61
+ console.log('API started for testing');
62
+ });
63
+
64
+ // Wait a bit for server to start
65
+ await new Promise(resolve => setTimeout(resolve, 100));
66
+
67
+ // Test API endpoints
68
+ const http = require('http');
69
+
70
+ // Helper function to make request
71
+ function makeRequest(options, data = null) {
72
+ return new Promise((resolve, reject) => {
73
+ const req = http.request(options, (res) => {
74
+ let body = '';
75
+ res.on('data', chunk => body += chunk);
76
+ res.on('end', () => {
77
+ try {
78
+ const json = JSON.parse(body);
79
+ resolve({ status: res.statusCode, data: json });
80
+ } catch (e) {
81
+ resolve({ status: res.statusCode, data: body });
82
+ }
83
+ });
84
+ });
85
+ req.on('error', reject);
86
+ if (data) {
87
+ req.write(JSON.stringify(data));
88
+ }
89
+ req.end();
90
+ });
91
+ }
92
+
93
+ // Test GET /events
94
+ console.log('Testing GET /events...');
95
+ let response = await makeRequest({ hostname: 'localhost', port: 3000, path: '/events', method: 'GET' });
96
+ console.log('GET /events status:', response.status, 'events:', response.data.length);
97
+
98
+ // Test POST /events
99
+ console.log('Testing POST /events...');
100
+ response = await makeRequest({
101
+ hostname: 'localhost', port: 3000, path: '/events', method: 'POST',
102
+ headers: { 'Content-Type': 'application/json' }
103
+ }, {
104
+ title: 'API Test Event',
105
+ description: 'Created via API',
106
+ startDate: '2023-10-03T14:00:00Z',
107
+ endDate: '2023-10-03T15:00:00Z'
108
+ });
109
+ console.log('POST /events status:', response.status, 'id:', response.data.id);
110
+ const apiEventId = response.data.id;
111
+
112
+ // Test PUT /events/:id
113
+ console.log('Testing PUT /events/:id...');
114
+ response = await makeRequest({
115
+ hostname: 'localhost', port: 3000, path: `/events/${apiEventId}`, method: 'PUT',
116
+ headers: { 'Content-Type': 'application/json' }
117
+ }, { title: 'Updated via API' });
118
+ console.log('PUT /events/:id status:', response.status);
119
+
120
+ // Test GET /export
121
+ console.log('Testing GET /export...');
122
+ response = await makeRequest({ hostname: 'localhost', port: 3000, path: '/export', method: 'GET' });
123
+ console.log('GET /export status:', response.status, 'ICS length:', response.data.length);
124
+
125
+ // Test DELETE /events/:id
126
+ console.log('Testing DELETE /events/:id...');
127
+ response = await makeRequest({ hostname: 'localhost', port: 3000, path: `/events/${apiEventId}`, method: 'DELETE' });
128
+ console.log('DELETE /events/:id status:', response.status);
129
+
130
+ // Close server
131
+ server.close(() => {
132
+ console.log('API server closed');
133
+ });
134
+
135
+ console.log('All tests completed successfully!');
136
+ }
137
+
138
+ test().catch(console.error);