add bounded ICS calendar import

This commit is contained in:
2026-09-06 18:40:29 +03:00
parent 5e19f3fbaa
commit a8c20ba280
4 changed files with 88 additions and 4 deletions
+6
View File
@@ -2092,3 +2092,9 @@ GitHub Actions iş akışı eklendi. Her push ve pull request’te test/üretim
`/api/ready` eklendi. `/api/health` süreç canlılığını korurken readiness kontrolü kullanıcı ve ortak veri dizinlerini gerektiğinde oluşturup okuma/yazma erişimini doğruluyor; başarısızlıkta HTTP 503 ve hangi kontrolün kaldığını döndürüyor. CI container kontrolü artık bu endpoint’i kullanıyor.
Doğrulama: HTTP readiness testi geçti; 290/290 otomatik test ve üretim derlemesi başarılı.
### 2026-09-06 · ICS takvim içe aktarma
Takvim ekranı artık Google/Outlook’tan dışa aktarılan `.ics` dosyalarını yerel oturumda içe alıyor. Ayrıştırıcı 200 KB ve 100 etkinlik sınırı uyguluyor, geçersiz tarihleri reddediyor ve harici etkinlikleri kaynak ihale kayıtlarından ayrı, salt okunur takvim girdileri olarak gösteriyor. OAuth ile otomatik senkronizasyon bu özelliğin kapsamı dışında tutuldu.
Doğrulama: ICS round-trip, İstanbul saat dönüşümü, kaçış karakterleri, bozuk tarih, boyut ve etkinlik sayısı sınırları test edildi; 292/292 test ve üretim derlemesi başarılı.
+17 -3
View File
@@ -1,5 +1,5 @@
import { useDeadlineClock } from '../../hooks/useDeadlineClock';
import { calendarMonthDays, istanbulDateKey, calendarAgenda, shiftCalendarMonth } from '../../services/tenderCalendar';
import { calendarMonthDays, istanbulDateKey, calendarAgenda, shiftCalendarMonth, parseTenderICS } from '../../services/tenderCalendar';
import React, { useState, useMemo } from 'react';
import { useApp } from '../../context/AppContext';
import { CalendarDays } from 'lucide-react';
@@ -10,11 +10,13 @@ export const TenderCalendar = () => {
const today = istanbulDateKey(now);
const [month,setMonth] = useState(today.slice(0,7));
const [selectedDay, setSelectedDay] = useState(today);
const agenda=useMemo(()=>calendarAgenda(tenders,now),[tenders,now]);
const [imported,setImported] = useState([]), [importError,setImportError] = useState('');
const allTenders = useMemo(() => [...tenders, ...imported], [tenders, imported]);
const agenda=useMemo(()=>calendarAgenda(allTenders,now),[allTenders,now]);
const daysList=calendarMonthDays(month).map(day=>({...day,count:agenda.days.get(day.key)?.length || 0}));
const dayTenders=agenda.days.get(selectedDay) || [];
const chooseMonth=value=>{if(calendarMonthDays(value).length){setMonth(value);setSelectedDay(value+'-01');}};
const card=({tender:t,info})=><button key={t.id} type="button" onClick={()=>setActiveTenderDetail(t)}
const card=({tender:t,info})=><button key={t.id} type="button" onClick={()=>!t.externalCalendar&&setActiveTenderDetail(t)} aria-disabled={t.externalCalendar || undefined}
className="w-full text-left bg-white rounded-xl border border-slate-200 p-4 shadow-xs hover:shadow-md transition-all flex flex-wrap items-center justify-between gap-3">
<span className="min-w-0 space-y-1">
<span className="flex flex-wrap gap-2 text-xs">
@@ -48,6 +50,18 @@ export const TenderCalendar = () => {
<input type="month" aria-label="Takvim ayı seç" value={month} onInput={e=>chooseMonth(e.currentTarget.value)} onChange={e=>chooseMonth(e.target.value)}
className="ml-3 border border-slate-300 rounded-lg p-2"/>
</label>
<section className="border border-slate-200 rounded-xl bg-white p-4 space-y-2" aria-label="Harici takvim içe aktarma">
<h2 className="text-sm font-bold">Harici takvimden içe aktar</h2>
<p className="text-xs text-slate-600">Google veya Outlook’tan indirdiğiniz .ics dosyasını yalnız bu tarayıcı oturumunda görüntüler. Kaynak ihale kaydını değiştirmez.</p>
<input type="file" accept=".ics,text/calendar" aria-label="ICS takvim dosyası seç" onChange={async event=>{
const file=event.target.files?.[0]; if(!file) return;
try { const parsed=parseTenderICS(await file.text()); setImported(parsed); setImportError(''); }
catch(error) { setImported([]); setImportError(error.message); }
event.target.value='';
}} className="block text-xs" />
{imported.length>0&&<p role="status" className="text-xs text-emerald-700">{imported.length} harici etkinlik içe aktarıldı.</p>}
{importError&&<p role="alert" className="text-xs text-red-700">Takvim içe aktarılamadı: {importError}</p>}
</section>
<div role="group" className="flex flex-wrap gap-2" aria-label="Takvim ayı gezinme">
<button type="button" aria-label="Önceki takvim ayı" className="border rounded px-3 py-2 text-sm" disabled={!shiftCalendarMonth(month,-1)} onClick={()=>chooseMonth(shiftCalendarMonth(month,-1))}>Önceki ay</button>
<button type="button" aria-label="Bugünün takvim ayına dön" className="border rounded px-3 py-2 text-sm" onClick={()=>{setMonth(today.slice(0,7));setSelectedDay(today);}}>Bugüne dön</button>
+49
View File
@@ -16,6 +16,7 @@ export function tenderDeadline(t) {
return new Date(Date.UTC(Number(year),Number(month)-1,Number(day),hour-3,minute));
}
const escapeICS = v => String(v || '').replace(/\\/g,'\\\\').replace(/\r\n|\r|\n/g,'\\n').replace(/;/g,'\\;').replace(/,/g,'\\,');
const unescapeICS = v => String(v || '').replace(/\\n/gi,'\n').replace(/\\([\\;,])/g,'$1');
const stamp = date => date.toISOString().replace(/[-:]/g,'').replace(/\.\d{3}Z$/,'Z');
function fold(line) {
const result = []; let current = '';
@@ -39,6 +40,54 @@ export function createTenderICS(t, now = new Date()) {
'END:VEVENT','END:VCALENDAR',''].map(fold).join('\r\n');
}
function parseICSDate(value, params = '') {
const raw = String(value || '').trim();
const match = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})Z?)?$/.exec(raw);
if (!match) return null;
const [, y, mo, d, hh, mm, ss] = match;
const date = new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(hh || 0), Number(mm || 0), Number(ss || 0)));
if (Number.isNaN(date.getTime())) return null;
if (date.getUTCFullYear() !== Number(y) || date.getUTCMonth() + 1 !== Number(mo) || date.getUTCDate() !== Number(d)
|| Number(hh || 0) > 23 || Number(mm || 0) > 59 || Number(ss || 0) > 59) return null;
const hasTime = Boolean(hh);
if (!hasTime) return { date: `${d}.${mo}.${y}`, time: '' };
// DTSTART with Z is UTC. TZID and unqualified values are treated as Istanbul,
// which is the application's documented calendar convention.
const isUtc = /Z$/.test(raw);
const instant = isUtc ? date : new Date(date.getTime() - 3 * 3600000);
const parts = new Intl.DateTimeFormat('tr-TR', { timeZone: 'Europe/Istanbul', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(instant);
const part = name => parts.find(item => item.type === name)?.value;
return { date: `${part('day')}.${part('month')}.${part('year')}`, time: `${part('hour')}:${part('minute')}` };
}
/** Parse a bounded VEVENT collection exported by this app or Google/Outlook. */
export function parseTenderICS(input) {
if (typeof input !== 'string' || input.length > 200_000) throw new Error('ICS dosyası boş veya çok büyük.');
const unfolded = input.replace(/\r?\n[ \t]/g, '').split(/\r?\n/);
const events = []; let current = null;
for (const line of unfolded) {
if (line === 'BEGIN:VEVENT') { if (current) throw new Error('ICS etkinlik yapısı geçersiz.'); current = {}; continue; }
if (line === 'END:VEVENT') { if (!current) throw new Error('ICS etkinlik yapısı geçersiz.'); events.push(current); current = null; continue; }
if (!current) continue;
const separator = line.indexOf(':'); if (separator < 1) continue;
const [property, ...parameterParts] = line.slice(0, separator).split(';');
const value = unescapeICS(line.slice(separator + 1));
if (property === 'DTSTART') current.dtstart = parseICSDate(value, parameterParts.join(';'));
else if (property === 'SUMMARY') current.summary = value;
else if (property === 'DESCRIPTION') current.description = value;
else if (property === 'UID') current.uid = value;
else if (property === 'SEQUENCE') current.sequence = Number(value);
}
if (current || !events.length) throw new Error('ICS etkinliği bulunamadı.');
if (events.length > 100) throw new Error('ICS dosyası en fazla 100 etkinlik içerebilir.');
return events.map((event, index) => {
if (!event.dtstart?.date || !event.summary?.trim()) throw new Error(`ICS etkinliği ${index + 1} tarih veya başlık içermiyor.`);
const ikn = /İKN:\s*([^\s—]+)/i.exec(event.description || '')?.[1] || '';
const institution = /İKN:\s*[^—]+—\s*([^\n]+)/i.exec(event.description || '')?.[1]?.trim() || 'Harici takvim';
return { id: `ics-${index}-${event.uid || 'event'}`, ikn, baslik: event.summary.replace(/^İhale son teklif:\s*/i, ''), kurum: institution, sehir: '', tarih: event.dtstart.date, saat: event.dtstart.time, durum: 'Harici takvim', alimTuru: '', externalCalendar: true, icsSequence: Number.isSafeInteger(event.sequence) ? event.sequence : 0 };
});
}
export const istanbulDateKey = (now = new Date()) => new Date(now.getTime() + 3 * 3600000).toISOString().slice(0,10);
export function tenderDateKey(t) {
const m = /^(\d{2})\.(\d{2})\.(\d{4})(?:$|\s)/.exec(String(t?.tarih || ''));
+16 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createTenderICS, tenderDeadline, deadlineInfo, calendarMonthDays, istanbulDateKey } from '../src/services/tenderCalendar.js';
import { createTenderICS, parseTenderICS, tenderDeadline, deadlineInfo, calendarMonthDays, istanbulDateKey } from '../src/services/tenderCalendar.js';
const t = {ikn:'2026/1582991',baslik:'Mikroskop',kurum:'Giresun',tarih:'10.09.2026',saat:'',aciklama:'Tarih ve Saati : 10.09.2026 - 10:00'};
test('Giresun date is September 10 at 10:00 Turkey, not hard-coded August', () => {
assert.equal(tenderDeadline(t).toISOString(),'2026-09-10T07:00:00.000Z');
@@ -96,3 +96,18 @@ test('calendar month navigation crosses year boundaries without invalid months',
assert.equal(shiftCalendarMonth('0001-01',-1),null);
assert.equal(shiftCalendarMonth('2026-13',1),null);
});
test('ICS import preserves Istanbul deadline, source identity and escaped text', () => {
const ics=createTenderICS({ikn:'2026/42',baslik:'Mikroskop',kurum:'Giresun Hastanesi',tarih:'10.09.2026',saat:'10:00'});
const imported=parseTenderICS(ics);
assert.equal(imported.length,1);
assert.deepEqual(imported[0],{id:imported[0].id,ikn:'2026/42',baslik:'Mikroskop',kurum:'Giresun Hastanesi',sehir:'',tarih:'10.09.2026',saat:'10:00',durum:'Harici takvim',alimTuru:'',externalCalendar:true,icsSequence:0});
});
test('ICS import rejects malformed, oversized and unbounded event collections', () => {
assert.throws(() => parseTenderICS('BEGIN:VCALENDAR\nEND:VCALENDAR'), /etkinliği bulunamadı/);
assert.throws(() => parseTenderICS('x'.repeat(200001)), /çok büyük/);
const many=Array.from({length:101},(_,i)=>`BEGIN:VEVENT\nUID:${i}\nDTSTART:20260910T100000Z\nSUMMARY:E${i}\nEND:VEVENT`).join('\n');
assert.throws(() => parseTenderICS(many), /en fazla 100/);
assert.throws(() => parseTenderICS('BEGIN:VEVENT\nUID:x\nDTSTART:20260231T100000Z\nSUMMARY:Bozuk\nEND:VEVENT'), /tarih veya başlık/);
});