add bounded ICS calendar import
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 || ''));
|
||||
|
||||
Reference in New Issue
Block a user