calender stuff
This commit is contained in:
1 parent
8e78468743
commit
e0fca064ae
15 files changed
+1716
-67
No files matched your search
@@ -119,7 +119,11 @@ hyprcrd/
|
||||
- **Split view** showing tag-related and content-related cards
|
||||
- **Import/Export** as JSON+media ZIP or Obsidian/Logseq-compatible markdown vault
|
||||
- **Drag & drop** card reordering and grouping
|
||||
- **Keyboard shortcuts**: `Ctrl+K` search, `Shift+scroll` zoom, arrow keys in autocomplete
|
||||
- **Calendar** with month view, journal entries, appointments, and day popup
|
||||
- **CalDAV sync** with ownCloud/Nextcloud (bidirectional, `#termin` tagged cards)
|
||||
- **Inline calculator** (`?3+5` → `8`, `34+56?` → `34+56=90`)
|
||||
- **Snippets** (`?dat`, `?time`, `?now`, `?todo`, `?table`, `?code`, ...)
|
||||
- **Keyboard shortcuts**: `Ctrl+K` search, `F1` help, `Shift+scroll` zoom, arrow keys in autocomplete
|
||||
|
||||
## Data & Backup
|
||||
|
||||
@@ -141,6 +145,65 @@ Back up both to preserve everything. The database auto-migrates on startup.
|
||||
image.jpg
|
||||
```
|
||||
|
||||
## CalDAV Sync (ownCloud / Nextcloud)
|
||||
|
||||
hyprcrd can sync appointment cards with any CalDAV server (tested with ownCloud 10.15.3.0).
|
||||
|
||||
### Setup
|
||||
|
||||
1. Open the **calendar** (button in header)
|
||||
2. Click the **gear icon** in the calendar header
|
||||
3. Enter your CalDAV URL, username, and app password:
|
||||
```
|
||||
URL: https://cloud.example.com/remote.php/dav/calendars/your-user/personal/
|
||||
Username: your-user
|
||||
Password: your-app-password
|
||||
```
|
||||
4. Click **test** to verify the connection
|
||||
5. Click **save**
|
||||
|
||||
### How it works
|
||||
|
||||
- Cards tagged with `#termin` are synced as calendar events
|
||||
- The appointment card template maps to iCalendar fields:
|
||||
```markdown
|
||||
Title: 2026-04-15 — Team Meeting
|
||||
|
||||
**Zeit:** 14:30-15:30
|
||||
**Ort:** Room 42
|
||||
**Teilnehmer:** Alice, Bob
|
||||
|
||||
---
|
||||
|
||||
**Notizen:**
|
||||
Agenda items here
|
||||
|
||||
#termin
|
||||
```
|
||||
- **Sync is bidirectional**: local changes push to the server, remote changes pull to hyprcrd
|
||||
- **Conflict resolution**: last-modified timestamp wins
|
||||
- **Deleted cards**: tracked via tombstones, deleted on the server on next sync
|
||||
- **Auto-sync**: triggers when opening the calendar (max once per minute)
|
||||
- **Manual sync**: click the cloud icon in the calendar header
|
||||
|
||||
### Security
|
||||
|
||||
- Credentials are encrypted at rest (AES-256-CBC) in the SQLite database
|
||||
- All sync operations run server-side (PHP/cURL), no client-side credential exposure
|
||||
- Supports HTTPS with certificate verification
|
||||
|
||||
## Calendar
|
||||
|
||||
The built-in calendar shows cards on their dates:
|
||||
|
||||
- **Journal cards** (title = `2026-04-08`) appear on that date
|
||||
- **Appointment cards** (title = `2026-04-08 — Subject`) appear on their date
|
||||
- **Other cards** fall back to their creation date
|
||||
- **Click a day** to see all cards in a popup
|
||||
- **Double-click** or use **+ journal** / **+ termin** to create new entries
|
||||
- Appointment template includes Zeit, Ort, Teilnehmer, Notizen fields
|
||||
- Navigate months with arrow buttons, **today** jumps to current date
|
||||
|
||||
## Rebuilding Tailwind CSS
|
||||
|
||||
After changing Tailwind utility classes in HTML or JS files:
|
||||
|
||||
+11
-2
@@ -13,8 +13,8 @@ body { font-family: 'JetBrains Mono', monospace; }
|
||||
.cal-cell-today:hover { background: color-mix(in srgb, var(--t-accent) 14%, transparent); }
|
||||
.cal-cell-num { font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 10px; color: var(--t-muted); margin-bottom: 3px; }
|
||||
.cal-cell-items { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; flex: 1; min-height: 0; }
|
||||
.cal-item { font-size: 10px; color: var(--t-text); padding: 2px 5px; border-left: 3px solid var(--t-accent); background: var(--t-surface); border-radius: 0 2px 2px 0; cursor: pointer; display: flex; align-items: center; gap: 4px; transition: background 0.1s; white-space: nowrap; overflow: hidden; flex-shrink: 0; }
|
||||
.cal-item:hover { background: var(--t-raised); color: var(--t-bright); }
|
||||
.cal-item { font-size: 10px; color: var(--t-text); padding: 2px 5px; border-left: 3px solid var(--t-accent); background: transparent; cursor: pointer; display: flex; align-items: center; gap: 4px; white-space: nowrap; overflow: hidden; flex-shrink: 0; }
|
||||
.cal-item:hover { text-decoration: underline dotted; text-underline-offset: 2px; color: var(--t-bright); }
|
||||
.cal-item-journal { opacity: 0.8; }
|
||||
.cal-todo { font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 8px; color: var(--t-accent); flex-shrink: 0; }
|
||||
|
||||
@@ -52,6 +52,15 @@ body::after {
|
||||
.line-clamp-3 { -webkit-line-clamp: 3; }
|
||||
.line-clamp-5 { -webkit-line-clamp: 5; }
|
||||
|
||||
/* Superview */
|
||||
.superview-grid { column-gap: 12px; padding: 1rem 1.5rem 6rem; }
|
||||
.superview-item { padding: 3px 0; display: flex; align-items: center; gap: 6px; cursor: pointer; break-inside: avoid; transition: color 0.1s; }
|
||||
.superview-item:hover { color: var(--t-bright); }
|
||||
.superview-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||
.superview-title { font-size: 11px; color: var(--t-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.superview-item:hover .superview-title { color: var(--t-bright); }
|
||||
.superview-icon { font-size: 10px; flex-shrink: 0; }
|
||||
|
||||
/* Cards */
|
||||
.card-bar { image-rendering: pixelated; height: 3px; }
|
||||
.board-card { min-height: 180px; }
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
|
||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-bold:700;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-sm:.25rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-Line truncated
|
||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-lg:32rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-bold:700;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-sm:.25rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkLine truncated
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../src/bootstrap.php';
|
||||
require_once __DIR__ . '/../src/CalDavClient.php';
|
||||
require_once __DIR__ . '/../src/CalDavSync.php';
|
||||
|
||||
$db = Database::connect();
|
||||
|
||||
@@ -163,6 +165,13 @@ if (str_starts_with($route, '/api/')) {
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'DELETE') {
|
||||
// CalDAV tombstone for synced cards
|
||||
$card = $db->prepare('SELECT caldav_uid FROM cards WHERE id = :id');
|
||||
$card->execute(['id' => $m[1]]);
|
||||
$uid = $card->fetchColumn();
|
||||
if ($uid) {
|
||||
$db->prepare('INSERT OR REPLACE INTO caldav_tombstones (caldav_uid, card_id) VALUES (?, ?)')->execute([$uid, $m[1]]);
|
||||
}
|
||||
$db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $m[1]]);
|
||||
echo json_encode(['ok' => true]); exit;
|
||||
}
|
||||
@@ -507,6 +516,27 @@ if (str_starts_with($route, '/api/')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// CalDAV endpoints
|
||||
$caldav = new CalDavSync($db);
|
||||
|
||||
if ($route === '/api/caldav/settings' && $method === 'GET') {
|
||||
echo json_encode($caldav->getSettings()); exit;
|
||||
}
|
||||
if ($route === '/api/caldav/settings' && $method === 'POST') {
|
||||
$caldav->saveSettings(json_decode(file_get_contents('php://input'), true) ?? []);
|
||||
echo json_encode(['ok' => true]); exit;
|
||||
}
|
||||
if ($route === '/api/caldav/test' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
echo json_encode($caldav->testConnection($input['url'] ?? null, $input['username'] ?? null, $input['password'] ?? null)); exit;
|
||||
}
|
||||
if ($route === '/api/caldav/sync' && $method === 'POST') {
|
||||
echo json_encode($caldav->sync()); exit;
|
||||
}
|
||||
if ($route === '/api/caldav/log' && $method === 'GET') {
|
||||
echo json_encode($db->query('SELECT * FROM caldav_sync_log ORDER BY id DESC LIMIT 50')->fetchAll()); exit;
|
||||
}
|
||||
|
||||
http_response_code(404); echo json_encode(['error' => 'not found']); exit;
|
||||
}
|
||||
|
||||
@@ -578,6 +608,12 @@ if (str_starts_with($route, '/api/')) {
|
||||
</div>
|
||||
<div id="bucket-row" class="flex items-center px-6 py-1.5 relative">
|
||||
<div class="flex items-center gap-1 overflow-x-auto flex-1" id="bucket-bar"></div>
|
||||
<button id="superview-toggle" class="flex items-center gap-1.5 px-2 py-1 text-[10px] font-pixel text-t-dim hover:text-t-accent transition-colors shrink-0 ml-2" title="compact list view">
|
||||
<i class="ph ph-list text-xs"></i>
|
||||
</button>
|
||||
<button id="hide-termine" class="flex items-center gap-1.5 px-2 py-1 text-[10px] font-pixel text-t-dim hover:text-t-accent transition-colors shrink-0 ml-2" title="toggle appointments">
|
||||
<i class="ph ph-calendar-x text-xs"></i>
|
||||
</button>
|
||||
<button id="sort-toggle" class="flex items-center gap-1.5 px-2 py-1 text-[10px] font-pixel text-t-dim hover:text-t-accent transition-colors shrink-0 ml-2">
|
||||
<i class="ph ph-sort-ascending text-xs"></i>
|
||||
<span id="sort-label">manual</span>
|
||||
@@ -920,6 +956,8 @@ if (str_starts_with($route, '/api/')) {
|
||||
<h2 id="cal-title" class="font-pixel text-xs text-t-accent min-w-[120px] text-center"></h2>
|
||||
<button id="cal-next" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-right text-sm"></i></button>
|
||||
<button id="cal-today" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">today</button>
|
||||
<button id="cal-sync" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors flex items-center gap-1"><i class="ph ph-cloud-arrow-up text-xs"></i>sync</button>
|
||||
<button id="cal-caldav-settings" class="text-t-dim hover:text-t-accent transition-colors"><i class="ph ph-gear text-sm"></i></button>
|
||||
<div class="flex-1"></div>
|
||||
<button id="cal-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
||||
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span>
|
||||
@@ -929,6 +967,39 @@ if (str_starts_with($route, '/api/')) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CalDAV Settings -->
|
||||
<div class="overlay fixed inset-0 z-[74] flex justify-center items-start pt-16 px-4" style="background:var(--t-overlay)" id="caldav-settings">
|
||||
<div class="bg-t-surface border border-t-border shadow-2xl w-full max-w-lg" style="border-radius:4px;">
|
||||
<div class="flex items-center gap-3 px-5 py-3 border-b border-t-border">
|
||||
<i class="ph ph-cloud-arrow-up text-t-accent text-base"></i>
|
||||
<h2 class="font-pixel text-xs text-t-accent">CalDAV Sync</h2>
|
||||
<div class="flex-1"></div>
|
||||
<button id="caldav-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span></button>
|
||||
</div>
|
||||
<div class="p-5 space-y-3">
|
||||
<div><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">server url</label><input id="caldav-url" type="text" placeholder="https://cloud.example.com/remote.php/dav/calendars/user/personal/" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none placeholder:text-t-dim font-mono"></div>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1"><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">username</label><input id="caldav-user" type="text" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono"></div>
|
||||
<div class="flex-1"><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">password</label><input id="caldav-pass" type="password" placeholder="app password" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="caldav-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plugs-connected text-sm"></i><span class="font-pixel text-[10px]">test</span></button>
|
||||
<span id="caldav-test-status" class="font-pixel text-[9px] text-t-dim"></span>
|
||||
</div>
|
||||
<div id="caldav-calendars" class="hidden border-t border-t-border pt-2">
|
||||
<label class="font-pixel text-[9px] text-t-dim uppercase block mb-2">calendars</label>
|
||||
<div id="caldav-cal-list" class="space-y-2"></div>
|
||||
</div>
|
||||
<div class="flex gap-3 pt-2 border-t border-t-border">
|
||||
<button id="caldav-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
|
||||
<button id="caldav-sync-now" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-arrows-clockwise text-sm"></i><span class="font-pixel text-[10px]">sync now</span></button>
|
||||
<span id="caldav-sync-status" class="font-pixel text-[9px] text-t-dim ml-auto self-center"></span>
|
||||
</div>
|
||||
<div id="caldav-log" class="text-[10px] text-t-dim font-mono max-h-32 overflow-y-auto hidden border-t border-t-border pt-2 mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gallery -->
|
||||
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="gallery">
|
||||
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
|
||||
|
||||
+5
-2
@@ -15,7 +15,8 @@ import { showContextMenu } from './modules/context-menu.js';
|
||||
import { undo, canUndo } from './modules/undo.js';
|
||||
import { openGallery, closeGallery } from './modules/gallery.js';
|
||||
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
|
||||
import { openCalendar, closeCalendar } from './modules/calendar.js';
|
||||
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
|
||||
import { startBackgroundSync, triggerSync } from './modules/caldav.js';
|
||||
|
||||
// ── Global API (for inline onclick handlers and cross-module calls) ──
|
||||
|
||||
@@ -26,13 +27,14 @@ window._closeSearch = closeSearch;
|
||||
window._closeTasks = closeTasks;
|
||||
window._renderBoard = renderBoard;
|
||||
window._openCalendar = openCalendar;
|
||||
window._triggerSync = triggerSync;
|
||||
window._renderTagBar = renderTagBar;
|
||||
window._showContextMenu = showContextMenu;
|
||||
window._ungroupCard = ungroupCard;
|
||||
window._getSelectedIds = getSelectedIds;
|
||||
window._clearSelection = clearSelection;
|
||||
window._refreshTasks = refreshTasksView;
|
||||
window._reloadAll = async () => { state.cards = await api('api/cards'); renderBoard(); };
|
||||
window._reloadAll = async () => { state.cards = await api('api/cards'); renderBoard(); refreshCalendar(); };
|
||||
window._extractToCard = async (text) => {
|
||||
if (!text?.trim()) return;
|
||||
const lines = text.trim().split('\n');
|
||||
@@ -134,6 +136,7 @@ async function checkAuth() {
|
||||
|
||||
loadBuckets();
|
||||
loadCards();
|
||||
startBackgroundSync();
|
||||
}
|
||||
|
||||
// ── Global keyboard ──
|
||||
|
||||
+180
-36
@@ -4,6 +4,7 @@ import { $, esc, api } from './util.js';
|
||||
import { state, sortedCards, parseTags, tagChipHtml, getChildren, getRecent, getFavorites, isFavorite, SORT_MODES, SORT_ICONS, CARD_COLORS } from './state.js';
|
||||
import { stripMd, getTodos, firstImage, leadingImage } from './markdown.js';
|
||||
import { pushReorder, pushCardEdit, pushBatch, snapshotCard } from './undo.js';
|
||||
import { getCalConfig } from './caldav.js';
|
||||
|
||||
const board = $('board');
|
||||
let activeDragEl = null;
|
||||
@@ -90,6 +91,39 @@ export function updateSortUI() {
|
||||
if (icon) icon.className = `ph ${SORT_ICONS[state.sortMode]} text-xs`;
|
||||
}
|
||||
|
||||
$('superview-toggle')?.addEventListener('click', () => {
|
||||
state.superView = !state.superView;
|
||||
localStorage.setItem('hyprcrd-superview', state.superView ? '1' : '0');
|
||||
const btn = $('superview-toggle');
|
||||
const icon = btn?.querySelector('i');
|
||||
if (icon) icon.className = `ph ${state.superView ? 'ph-squares-four' : 'ph-list'} text-xs`;
|
||||
if (btn) btn.classList.toggle('text-t-accent', state.superView);
|
||||
renderBoard();
|
||||
});
|
||||
if (state.superView) {
|
||||
const btn = $('superview-toggle');
|
||||
const icon = btn?.querySelector('i');
|
||||
if (icon) icon.className = 'ph ph-squares-four text-xs';
|
||||
if (btn) btn.classList.add('text-t-accent');
|
||||
}
|
||||
|
||||
$('hide-termine')?.addEventListener('click', () => {
|
||||
state.hideTermine = !state.hideTermine;
|
||||
localStorage.setItem('hyprcrd-hide-termine', state.hideTermine ? '1' : '0');
|
||||
const btn = $('hide-termine');
|
||||
const icon = btn?.querySelector('i');
|
||||
if (icon) icon.className = `ph ${state.hideTermine ? 'ph-calendar-check' : 'ph-calendar-x'} text-xs`;
|
||||
if (btn) btn.classList.toggle('text-t-accent', state.hideTermine);
|
||||
renderBoard();
|
||||
});
|
||||
// Init toggle state
|
||||
if (state.hideTermine) {
|
||||
const btn = $('hide-termine');
|
||||
const icon = btn?.querySelector('i');
|
||||
if (icon) icon.className = 'ph ph-calendar-check text-xs';
|
||||
if (btn) btn.classList.add('text-t-accent');
|
||||
}
|
||||
|
||||
$('sort-toggle')?.addEventListener('click', () => {
|
||||
state.sortMode = SORT_MODES[(SORT_MODES.indexOf(state.sortMode) + 1) % SORT_MODES.length];
|
||||
localStorage.setItem('hyprcrd-sort', state.sortMode);
|
||||
@@ -97,6 +131,49 @@ $('sort-toggle')?.addEventListener('click', () => {
|
||||
renderBoard();
|
||||
});
|
||||
|
||||
function renderCardHtml(c, inGroup = false, recent = []) {
|
||||
const coverImg = leadingImage(c.body);
|
||||
const thumbImg = coverImg ? null : firstImage(c.body);
|
||||
const children = getChildren(c.id);
|
||||
const isGroup = !inGroup && children.length > 0;
|
||||
const todos = getTodos(c.body);
|
||||
const todoPct = todos ? Math.round(todos.done / todos.total * 100) : 0;
|
||||
const hasCover = !!coverImg;
|
||||
const tags = parseTags(c);
|
||||
const isTermin = tags.includes('termin') || tags.includes('geburtstag');
|
||||
let calCfg = null;
|
||||
if (isTermin) { for (const t of tags) { if (t && t !== 'termin' && t !== 'geburtstag') { const cfg = getCalConfig(t); if (cfg) { calCfg = cfg; break; } } } }
|
||||
const cardColor = calCfg?.color || c.color;
|
||||
const calIcon = isTermin ? (calCfg?.icon || 'calendar-dots') : null;
|
||||
|
||||
return `
|
||||
<div class="board-card ${isGroup ? 'card-group ' : ''}card-glow ${hasCover ? 'card-has-cover' : 'bg-t-surface'} border border-t-border cursor-pointer hover:border-t-muted transition-colors relative group"
|
||||
style="--glow-color:${cardColor}22;${hasCover ? `background-image:url('${coverImg}')` : ''};${recent.includes(String(c.id)) ? 'border-bottom:3px solid ' + cardColor : ''}" draggable="true" data-id="${c.id}">
|
||||
${hasCover ? '<div class="card-cover-overlay"></div>' : ''}
|
||||
<div class="card-bar w-full card-layer" style="background:${cardColor}"></div>
|
||||
${isGroup ? `<div class="card-group-badge cursor-pointer hover:scale-110 transition-transform" style="background:${cardColor};color:#000" data-group="${c.id}">${children.length + 1}</div>` : ''}
|
||||
${thumbImg ? `<div class="card-thumb" style="background-image:url('${thumbImg}')"></div>` : ''}
|
||||
<div class="p-4 card-layer flex flex-col h-full ${thumbImg ? 'pr-16' : ''}">
|
||||
<div class="flex items-start gap-2 mb-2">
|
||||
${calIcon ? `<i class="ph ph-${calIcon} text-xs mt-0.5 shrink-0" style="color:${cardColor}"></i>` : ''}
|
||||
${isFavorite(c.id) ? `<i class="ph-fill ph-star text-amber-400 text-xs mt-0.5 shrink-0"></i>` : ''}
|
||||
${todos ? `<i class="ph ${todos.done === todos.total ? 'ph-check-square text-t-accent' : 'ph-squares-four text-t-muted'} text-sm mt-0.5 shrink-0"></i>` : ''}
|
||||
<h3 class="font-pixel text-xs text-t-bright leading-relaxed line-clamp-2">${esc(c.title)}</h3>
|
||||
</div>
|
||||
${todos ? `<div class="mb-2"><div class="flex items-center gap-2 mb-1">
|
||||
<div class="flex-1 h-1.5 bg-t-border rounded-full overflow-hidden"><div class="h-full rounded-full transition-all" style="width:${todoPct}%;background:${cardColor}"></div></div>
|
||||
<span class="font-pixel text-[9px] ${todos.done === todos.total ? 'text-t-accent' : 'text-t-dim'}">${todos.done}/${todos.total}</span>
|
||||
</div></div>` : ''}
|
||||
<p class="text-xs ${hasCover ? 'text-t-text' : 'text-t-muted'} leading-relaxed line-clamp-5 flex-1">${esc(stripMd(c.body))}</p>
|
||||
${tags.length ? `<div class="flex flex-wrap gap-1 mt-1.5 card-layer">${tags.slice(0, 5).map(t => tagChipHtml(t)).join('')}${tags.length > 5 ? `<span class="tag-chip tag-chip-more">+${tags.length - 5}</span>` : ''}</div>` : ''}
|
||||
<div class="mt-auto pt-2 flex items-center gap-2 text-[10px] ${hasCover ? 'text-t-text' : 'text-t-dim'} font-pixel">
|
||||
<span class="flex items-center gap-1"><i class="ph ph-clock text-xs"></i>${(c.updated_at || c.created_at || '').substring(0, 10)}</span>
|
||||
${c.bucket_name ? `<span class="flex items-center gap-1"><i class="ph ph-folder text-xs"></i>${esc(c.bucket_name)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render ──
|
||||
|
||||
export function renderBoard() {
|
||||
@@ -122,7 +199,8 @@ export function renderBoard() {
|
||||
const favs = getFavorites();
|
||||
cards = cards.filter(c => favs.includes(String(c.id)));
|
||||
} else if (state.activeBucket === 'journal') {
|
||||
cards = cards.filter(c => /^\d{4}-\d{2}-\d{2}/.test(c.title));
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
cards = cards.filter(c => /^\d{4}-\d{2}-\d{2}/.test(c.title) && c.title.substring(0, 10) <= today);
|
||||
cards.sort((a, b) => b.title.localeCompare(a.title));
|
||||
} else if (state.activeBucket === 'none') {
|
||||
cards = cards.filter(c => !c.bucket_id && !/^\d{4}-\d{2}-\d{2}/.test(c.title));
|
||||
@@ -131,6 +209,10 @@ export function renderBoard() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hide appointments
|
||||
if (state.hideTermine) {
|
||||
cards = cards.filter(c => !parseTags(c).includes('termin'));
|
||||
}
|
||||
// Tag filter (intersects with bucket filter) — hierarchical: #nase matches #nase/popel
|
||||
if (state.activeTags.length) {
|
||||
cards = cards.filter(c => {
|
||||
@@ -149,6 +231,7 @@ export function renderBoard() {
|
||||
renderGroupBar(cards);
|
||||
|
||||
if (!cards.length) {
|
||||
board.style.display = '';
|
||||
board.innerHTML = `<div class="col-span-full text-center py-20">
|
||||
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i>
|
||||
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`;
|
||||
@@ -160,42 +243,103 @@ export function renderBoard() {
|
||||
const recent = getRecent();
|
||||
const inGroup = !!state.activeGroup;
|
||||
|
||||
board.innerHTML = cards.map(c => {
|
||||
const coverImg = leadingImage(c.body);
|
||||
const thumbImg = coverImg ? null : firstImage(c.body);
|
||||
const children = getChildren(c.id);
|
||||
const isGroup = !inGroup && children.length > 0;
|
||||
const todos = getTodos(c.body);
|
||||
const todoPct = todos ? Math.round(todos.done / todos.total * 100) : 0;
|
||||
const hasCover = !!coverImg;
|
||||
const tags = parseTags(c);
|
||||
if (state.superView) {
|
||||
// Compact multi-column list view — override board grid
|
||||
board.style.display = 'block';
|
||||
const cols = cards.length <= 20 ? 2 : cards.length <= 50 ? 3 : cards.length <= 100 ? 4 : 5;
|
||||
board.innerHTML = `<div class="superview-grid" style="column-count:${cols};">` + cards.map(c => {
|
||||
const tags = parseTags(c);
|
||||
const isTermin = tags.includes('termin') || tags.includes('geburtstag');
|
||||
let calCfg = null;
|
||||
if (isTermin) { for (const t of tags) { if (t !== 'termin' && t !== 'geburtstag') { const cfg = getCalConfig(t); if (cfg) { calCfg = cfg; break; } } } }
|
||||
const dotColor = calCfg?.color || c.color;
|
||||
const icon = isTermin ? (calCfg?.icon || 'calendar-dots') : (isFavorite(c.id) ? 'star' : null);
|
||||
const iconColor = isTermin ? dotColor : (isFavorite(c.id) ? '#fbbf24' : 'var(--t-dim)');
|
||||
return `<div class="superview-item" data-id="${c.id}">
|
||||
<span class="superview-dot" style="background:${dotColor}"></span>
|
||||
${icon ? `<i class="ph ph-${icon} superview-icon" style="color:${iconColor}"></i>` : ''}
|
||||
<span class="superview-title">${esc(c.title)}</span>
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
|
||||
return `
|
||||
<div class="board-card ${isGroup ? 'card-group ' : ''}card-glow ${hasCover ? 'card-has-cover' : 'bg-t-surface'} border border-t-border cursor-pointer hover:border-t-muted transition-colors relative group"
|
||||
style="--glow-color:${c.color}22;${hasCover ? `background-image:url('${coverImg}')` : ''};${recent.includes(String(c.id)) ? 'border-bottom:3px solid ' + c.color : ''}" draggable="true" data-id="${c.id}">
|
||||
${hasCover ? '<div class="card-cover-overlay"></div>' : ''}
|
||||
<div class="card-bar w-full card-layer" style="background:${c.color}"></div>
|
||||
${isGroup ? `<div class="card-group-badge cursor-pointer hover:scale-110 transition-transform" style="background:${c.color};color:#000" data-group="${c.id}">${children.length + 1}</div>` : ''}
|
||||
${thumbImg ? `<div class="card-thumb" style="background-image:url('${thumbImg}')"></div>` : ''}
|
||||
<div class="p-4 card-layer flex flex-col h-full ${thumbImg ? 'pr-16' : ''}">
|
||||
<div class="flex items-start gap-2 mb-2">
|
||||
${isFavorite(c.id) ? `<i class="ph-fill ph-star text-amber-400 text-xs mt-0.5 shrink-0"></i>` : ''}
|
||||
${todos ? `<i class="ph ${todos.done === todos.total ? 'ph-check-square text-t-accent' : 'ph-squares-four text-t-muted'} text-sm mt-0.5 shrink-0"></i>` : ''}
|
||||
<h3 class="font-pixel text-xs text-t-bright leading-relaxed line-clamp-2">${esc(c.title)}</h3>
|
||||
</div>
|
||||
${todos ? `<div class="mb-2"><div class="flex items-center gap-2 mb-1">
|
||||
<div class="flex-1 h-1.5 bg-t-border rounded-full overflow-hidden"><div class="h-full rounded-full transition-all" style="width:${todoPct}%;background:${c.color}"></div></div>
|
||||
<span class="font-pixel text-[9px] ${todos.done === todos.total ? 'text-t-accent' : 'text-t-dim'}">${todos.done}/${todos.total}</span>
|
||||
</div></div>` : ''}
|
||||
<p class="text-xs ${hasCover ? 'text-t-text' : 'text-t-muted'} leading-relaxed line-clamp-5 flex-1">${esc(stripMd(c.body))}</p>
|
||||
${tags.length ? `<div class="flex flex-wrap gap-1 mt-1.5 card-layer">${tags.slice(0, 5).map(t => tagChipHtml(t)).join('')}${tags.length > 5 ? `<span class="tag-chip tag-chip-more">+${tags.length - 5}</span>` : ''}</div>` : ''}
|
||||
<div class="mt-auto pt-2 flex items-center gap-2 text-[10px] ${hasCover ? 'text-t-text' : 'text-t-dim'} font-pixel">
|
||||
<span class="flex items-center gap-1"><i class="ph ph-clock text-xs"></i>${(c.updated_at || c.created_at || '').substring(0, 10)}</span>
|
||||
${c.bucket_name ? `<span class="flex items-center gap-1"><i class="ph ph-folder text-xs"></i>${esc(c.bucket_name)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
let hoverPopup = null, hoverTimer = null, hoverCloseTimer = null;
|
||||
function clearHover() {
|
||||
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
|
||||
if (hoverCloseTimer) { clearTimeout(hoverCloseTimer); hoverCloseTimer = null; }
|
||||
if (hoverPopup) { hoverPopup.remove(); hoverPopup = null; }
|
||||
}
|
||||
function delayedClose() {
|
||||
hoverCloseTimer = setTimeout(clearHover, 150);
|
||||
}
|
||||
function cancelClose() {
|
||||
if (hoverCloseTimer) { clearTimeout(hoverCloseTimer); hoverCloseTimer = null; }
|
||||
}
|
||||
|
||||
board.querySelectorAll('.superview-item[data-id]').forEach(el => {
|
||||
el.addEventListener('click', () => { clearHover(); window._openDetail(el.dataset.id); });
|
||||
el.addEventListener('dblclick', () => { clearHover(); const card = state.cards.find(c => c.id == el.dataset.id); if (card) window._openModal(card); });
|
||||
el.addEventListener('contextmenu', e => { e.preventDefault(); clearHover(); const card = state.cards.find(c => c.id == el.dataset.id); if (card) window._showContextMenu(e.clientX, e.clientY, card); });
|
||||
|
||||
el.addEventListener('mouseenter', () => {
|
||||
cancelClose();
|
||||
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
|
||||
hoverTimer = setTimeout(() => {
|
||||
if (hoverPopup) { hoverPopup.remove(); hoverPopup = null; }
|
||||
const card = state.cards.find(c => c.id == el.dataset.id);
|
||||
if (!card) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
hoverPopup = document.createElement('div');
|
||||
Object.assign(hoverPopup.style, {
|
||||
position: 'fixed', zIndex: '100',
|
||||
width: '280px',
|
||||
filter: 'drop-shadow(0 8px 24px rgba(0,0,0,0.4))',
|
||||
});
|
||||
const textEnd = el.querySelector('.superview-title');
|
||||
const textRect = textEnd ? textEnd.getBoundingClientRect() : rect;
|
||||
const left = textRect.right + 8;
|
||||
const useLeft = left + 290 < window.innerWidth;
|
||||
hoverPopup.style.left = (useLeft ? left : textRect.left - 288) + 'px';
|
||||
hoverPopup.style.top = Math.min(rect.top - 20, window.innerHeight - 320) + 'px';
|
||||
hoverPopup.innerHTML = renderCardHtml(card);
|
||||
hoverPopup.addEventListener('mouseenter', e => { e.stopPropagation(); cancelClose(); });
|
||||
hoverPopup.addEventListener('mouseleave', delayedClose);
|
||||
hoverPopup.addEventListener('mouseover', e => e.stopPropagation());
|
||||
// Tag clicks inside hover card
|
||||
hoverPopup.querySelectorAll('.tag-seg[data-tag]').forEach(seg => {
|
||||
seg.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const tag = seg.dataset.tag;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const idx = state.activeTags.indexOf(tag);
|
||||
if (idx >= 0) state.activeTags.splice(idx, 1);
|
||||
else state.activeTags.push(tag);
|
||||
} else {
|
||||
state.activeTags = state.activeTags.length === 1 && state.activeTags[0] === tag ? [] : [tag];
|
||||
}
|
||||
clearHover();
|
||||
window._renderTagBar?.();
|
||||
renderBoard();
|
||||
});
|
||||
});
|
||||
// Click on hover card opens detail
|
||||
hoverPopup.querySelector('.board-card')?.addEventListener('click', () => {
|
||||
clearHover();
|
||||
window._openDetail(card.id);
|
||||
});
|
||||
document.body.appendChild(hoverPopup);
|
||||
}, 400);
|
||||
});
|
||||
el.addEventListener('mouseleave', delayedClose);
|
||||
});
|
||||
|
||||
const fc = $('footer-count');
|
||||
if (fc) fc.textContent = `${cards.length} cards · ${state.cards.length} total`;
|
||||
return;
|
||||
}
|
||||
|
||||
board.style.display = '';
|
||||
board.innerHTML = cards.map(c => renderCardHtml(c, inGroup, recent)).join('');
|
||||
|
||||
bindCardEvents();
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
// ── CalDAV Sync ──
|
||||
|
||||
import { $, esc, api, getCsrf } from './util.js';
|
||||
|
||||
const CAL_ICONS = [
|
||||
{ id: 'calendar-dots', label: 'calendar' },
|
||||
{ id: 'briefcase', label: 'work' },
|
||||
{ id: 'house', label: 'home' },
|
||||
{ id: 'heart', label: 'personal' },
|
||||
{ id: 'cake', label: 'birthday' },
|
||||
{ id: 'graduation-cap', label: 'education' },
|
||||
{ id: 'airplane', label: 'travel' },
|
||||
{ id: 'basketball', label: 'sport' },
|
||||
{ id: 'first-aid', label: 'health' },
|
||||
{ id: 'star', label: 'important' },
|
||||
{ id: 'users', label: 'team' },
|
||||
{ id: 'phone', label: 'calls' },
|
||||
];
|
||||
const CAL_COLORS = ['#38bdf8', '#8b5cf6', '#fb7185', '#fbbf24', '#4ade80', '#fb923c', '#22d3ee', '#f472b6', '#a3e635', '#94a3b8'];
|
||||
|
||||
let calendarConfigs = {}; // slug → { color, icon }
|
||||
const CAL_CONFIG_KEY = 'hyprcrd-cal-configs';
|
||||
|
||||
const overlay = $('caldav-settings');
|
||||
const urlInput = $('caldav-url');
|
||||
const userInput = $('caldav-user');
|
||||
const passInput = $('caldav-pass');
|
||||
const testStatus = $('caldav-test-status');
|
||||
const syncStatus = $('caldav-sync-status');
|
||||
const logEl = $('caldav-log');
|
||||
|
||||
export function openCaldavSettings() {
|
||||
overlay?.classList.add('open');
|
||||
loadSettings();
|
||||
}
|
||||
|
||||
export function closeCaldavSettings() {
|
||||
overlay?.classList.remove('open');
|
||||
}
|
||||
|
||||
function loadCalConfigs() {
|
||||
try { calendarConfigs = JSON.parse(localStorage.getItem(CAL_CONFIG_KEY) || '{}'); } catch { calendarConfigs = {}; }
|
||||
}
|
||||
function saveCalConfigs() {
|
||||
localStorage.setItem(CAL_CONFIG_KEY, JSON.stringify(calendarConfigs));
|
||||
}
|
||||
export function getCalConfig(slug) {
|
||||
loadCalConfigs();
|
||||
return calendarConfigs[slug] || null;
|
||||
}
|
||||
|
||||
export function getAllCalConfigs() {
|
||||
loadCalConfigs();
|
||||
return calendarConfigs;
|
||||
}
|
||||
|
||||
function renderCalList(calendars) {
|
||||
const container = $('caldav-cal-list');
|
||||
const wrap = $('caldav-calendars');
|
||||
if (!container || !wrap || !calendars?.length) return;
|
||||
loadCalConfigs();
|
||||
wrap.classList.remove('hidden');
|
||||
container.innerHTML = calendars.map(cal => {
|
||||
const slug = cal.name.toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
||||
const cfg = calendarConfigs[slug] || {};
|
||||
const activeColor = cfg.color || '#94a3b8';
|
||||
const activeIcon = cfg.icon || 'calendar-dots';
|
||||
return `<div class="flex items-center gap-2 py-1" data-cal-slug="${slug}">
|
||||
<span style="width:10px;height:10px;border-radius:2px;background:${activeColor};flex-shrink:0;"></span>
|
||||
<span class="font-pixel text-[10px] text-t-bright flex-1">${esc(cal.name)}</span>
|
||||
<div class="flex gap-1">
|
||||
${CAL_COLORS.map(c => `<button class="cal-cfg-color" data-color="${c}" style="width:14px;height:14px;border-radius:2px;background:${c};border:2px solid ${c === activeColor ? 'var(--t-bright)' : 'transparent'};cursor:pointer;"></button>`).join('')}
|
||||
</div>
|
||||
<div class="flex gap-1 ml-1">
|
||||
${CAL_ICONS.slice(0, 6).map(ic => `<button class="cal-cfg-icon" data-icon="${ic.id}" title="${ic.label}" style="font-size:12px;cursor:pointer;padding:1px;border-radius:2px;${ic.id === activeIcon ? 'color:var(--t-accent);' : 'color:var(--t-dim);'}"><i class="ph ph-${ic.id}"></i></button>`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
container.querySelectorAll('.cal-cfg-color').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const slug = btn.closest('[data-cal-slug]').dataset.calSlug;
|
||||
if (!calendarConfigs[slug]) calendarConfigs[slug] = {};
|
||||
calendarConfigs[slug].color = btn.dataset.color;
|
||||
saveCalConfigs();
|
||||
renderCalList(calendars);
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('.cal-cfg-icon').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const slug = btn.closest('[data-cal-slug]').dataset.calSlug;
|
||||
if (!calendarConfigs[slug]) calendarConfigs[slug] = {};
|
||||
calendarConfigs[slug].icon = btn.dataset.icon;
|
||||
saveCalConfigs();
|
||||
renderCalList(calendars);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const s = await api('api/caldav/settings');
|
||||
if (!s) return;
|
||||
if (urlInput) urlInput.value = s.url || '';
|
||||
if (userInput) userInput.value = s.username || '';
|
||||
if (passInput) passInput.value = '';
|
||||
if (passInput && s.has_password) passInput.placeholder = '(saved)';
|
||||
if (syncStatus) syncStatus.textContent = s.last_sync ? `last sync: ${s.last_sync}` : '';
|
||||
// Load calendar list if configured
|
||||
if (s.url && s.has_password) {
|
||||
const test = await fetch('api/caldav/test', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
||||
body: '{}',
|
||||
}).then(r => r.json()).catch(() => null);
|
||||
if (test?.calendars) renderCalList(test.calendars);
|
||||
}
|
||||
}
|
||||
|
||||
$('caldav-close')?.addEventListener('click', closeCaldavSettings);
|
||||
overlay?.addEventListener('click', e => { if (e.target === overlay) closeCaldavSettings(); });
|
||||
|
||||
$('caldav-test')?.addEventListener('click', async () => {
|
||||
testStatus.textContent = 'testing...';
|
||||
testStatus.style.color = 'var(--t-dim)';
|
||||
const body = { url: urlInput.value, username: userInput.value };
|
||||
if (passInput.value) body.password = passInput.value;
|
||||
const res = await fetch('api/caldav/test', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
||||
body: JSON.stringify(body),
|
||||
}).then(r => r.json()).catch(() => ({ ok: false, error: 'network error' }));
|
||||
|
||||
if (res.ok) {
|
||||
const names = (res.calendars || []).map(c => c.name).join(', ');
|
||||
testStatus.textContent = `connected (${names || 'no calendars'})`;
|
||||
testStatus.style.color = 'var(--t-accent)';
|
||||
if (res.calendars) renderCalList(res.calendars);
|
||||
} else {
|
||||
testStatus.textContent = res.error || 'failed';
|
||||
testStatus.style.color = 'var(--t-danger)';
|
||||
}
|
||||
showConnectionLog(res.log);
|
||||
});
|
||||
|
||||
$('caldav-save')?.addEventListener('click', async () => {
|
||||
const body = { url: urlInput.value, username: userInput.value, enabled: true };
|
||||
if (passInput.value) body.password = passInput.value;
|
||||
await api('api/caldav/settings', { method: 'POST', body });
|
||||
testStatus.textContent = 'saved';
|
||||
testStatus.style.color = 'var(--t-accent)';
|
||||
});
|
||||
|
||||
$('caldav-sync-now')?.addEventListener('click', () => triggerSync());
|
||||
|
||||
export async function triggerSync() {
|
||||
const btn = $('cal-sync');
|
||||
if (btn) btn.style.opacity = '0.5';
|
||||
if (syncStatus) { syncStatus.textContent = 'syncing...'; syncStatus.style.color = 'var(--t-dim)'; }
|
||||
|
||||
const res = await fetch('api/caldav/sync', {
|
||||
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
||||
}).then(r => r.json()).catch(() => ({ ok: false, error: 'network error' }));
|
||||
|
||||
if (btn) btn.style.opacity = '';
|
||||
|
||||
if (res.ok) {
|
||||
const s = res.stats || {};
|
||||
const hasErrors = (res.log || []).some(l => l.status === 'error');
|
||||
const msg = `pushed:${s.pushed || 0} pulled:${s.pulled || 0} del:${s.deleted || 0}${s.conflicts ? ' conflicts:' + s.conflicts : ''}`;
|
||||
if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
|
||||
await window._reloadAll?.();
|
||||
showLog(res.log);
|
||||
showSyncNotification(s);
|
||||
} else {
|
||||
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
|
||||
}
|
||||
}
|
||||
|
||||
function showLog(log) {
|
||||
if (!logEl || !log?.length) return;
|
||||
logEl.classList.remove('hidden');
|
||||
logEl.innerHTML = log.map(l =>
|
||||
`<div class="${l.status === 'error' ? 'text-t-danger' : 'text-t-dim'}">${l.dir} ${l.action} ${l.status}${l.detail ? ' — ' + l.detail : ''}</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function showConnectionLog(lines) {
|
||||
if (!logEl || !lines?.length) return;
|
||||
logEl.classList.remove('hidden');
|
||||
logEl.innerHTML = lines.map(l =>
|
||||
`<div class="${l.startsWith('ERROR') || l.startsWith('EXCEPTION') ? 'text-t-danger' : l.startsWith('OK') ? 'text-t-accent' : 'text-t-dim'}">${l.replace(/</g, '<')}</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
$('cal-caldav-settings')?.addEventListener('click', openCaldavSettings);
|
||||
$('cal-sync')?.addEventListener('click', () => triggerSync());
|
||||
|
||||
// Auto-sync throttle
|
||||
let lastAutoSync = 0;
|
||||
export function autoSync() {
|
||||
const now = Date.now();
|
||||
if (now - lastAutoSync < 60000) return;
|
||||
lastAutoSync = now;
|
||||
triggerSync();
|
||||
}
|
||||
|
||||
// Background sync every 2 minutes
|
||||
function showSyncNotification(stats) {
|
||||
const s = stats || {};
|
||||
const parts = [];
|
||||
if (s.pulled) parts.push(`${s.pulled} received`);
|
||||
if (s.pushed) parts.push(`${s.pushed} sent`);
|
||||
if (s.deleted) parts.push(`${s.deleted} deleted`);
|
||||
if (!parts.length) return;
|
||||
|
||||
const note = document.createElement('div');
|
||||
note.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;';
|
||||
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ph-cloud-arrow-down"></i>CalDAV: ${parts.join(', ')}</div>`;
|
||||
document.body.appendChild(note);
|
||||
setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000);
|
||||
}
|
||||
|
||||
async function backgroundSync() {
|
||||
try {
|
||||
const res = await fetch('api/caldav/sync', {
|
||||
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
||||
}).then(r => r.json()).catch(() => null);
|
||||
if (res?.ok) {
|
||||
const s = res.stats || {};
|
||||
if (s.pulled > 0 || s.pushed > 0 || s.deleted > 0) {
|
||||
await window._reloadAll?.();
|
||||
showSyncNotification(s);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Start background sync after app is ready
|
||||
let bgSyncInterval = null;
|
||||
export function startBackgroundSync() {
|
||||
if (bgSyncInterval) return;
|
||||
backgroundSync(); // first sync immediately
|
||||
bgSyncInterval = setInterval(backgroundSync, 120000); // then every 2 minutes
|
||||
}
|
||||
+236
-21
@@ -3,6 +3,7 @@
|
||||
import { $, esc } from './util.js';
|
||||
import { state } from './state.js';
|
||||
import { stripMd, getTodos } from './markdown.js';
|
||||
import { autoSync, getCalConfig, getAllCalConfigs } from './caldav.js';
|
||||
|
||||
const overlay = $('calendar');
|
||||
const grid = $('cal-grid');
|
||||
@@ -16,19 +17,126 @@ function pad(n) { return String(n).padStart(2, '0'); }
|
||||
|
||||
function dateStr(y, m, d) { return `${y}-${pad(m + 1)}-${pad(d)}`; }
|
||||
|
||||
function cardDate(c) {
|
||||
// Title starts with date (journal "2026-04-08" or appointment "2026-04-08 — Meeting")
|
||||
function cardBaseDate(c) {
|
||||
const m = (c.title || '').match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
if (m) return m[1];
|
||||
// Fallback: created date
|
||||
return (c.created_at || '').substring(0, 10);
|
||||
}
|
||||
|
||||
function getCardsForDate(ds) {
|
||||
return state.cards.filter(c => cardDate(c) === ds);
|
||||
function parseRRule(rrule) {
|
||||
if (!rrule) return null;
|
||||
const parts = {};
|
||||
for (const p of rrule.split(';')) {
|
||||
const [k, v] = p.split('=');
|
||||
parts[k] = v;
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function isJournal(card) { return /^\d{4}-\d{2}-\d{2}/.test(card.title); }
|
||||
function matchesRRule(card, targetDateStr) {
|
||||
const rrule = parseRRule(card.caldav_rrule);
|
||||
if (!rrule || !rrule.FREQ) return false;
|
||||
|
||||
const base = cardBaseDate(card);
|
||||
if (!base) return false;
|
||||
|
||||
const baseDate = new Date(base + 'T00:00:00');
|
||||
const target = new Date(targetDateStr + 'T00:00:00');
|
||||
|
||||
// Don't match before the start date
|
||||
if (target < baseDate) return false;
|
||||
// Same date is handled by cardBaseDate match
|
||||
if (targetDateStr === base) return false;
|
||||
|
||||
// COUNT / UNTIL limits
|
||||
const count = rrule.COUNT ? parseInt(rrule.COUNT) : null;
|
||||
const until = rrule.UNTIL ? new Date(rrule.UNTIL.substring(0, 4) + '-' + rrule.UNTIL.substring(4, 6) + '-' + rrule.UNTIL.substring(6, 8) + 'T23:59:59') : null;
|
||||
if (until && target > until) return false;
|
||||
|
||||
const interval = parseInt(rrule.INTERVAL || '1');
|
||||
const freq = rrule.FREQ;
|
||||
|
||||
if (freq === 'DAILY') {
|
||||
const diff = Math.round((target - baseDate) / 86400000);
|
||||
if (diff % interval !== 0) return false;
|
||||
return !count || (diff / interval) < count;
|
||||
}
|
||||
if (freq === 'WEEKLY') {
|
||||
const diff = Math.round((target - baseDate) / 86400000);
|
||||
if (rrule.BYDAY) {
|
||||
const dayMap = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };
|
||||
const allowed = rrule.BYDAY.split(',').map(d => dayMap[d]);
|
||||
if (!allowed.includes(target.getDay())) return false;
|
||||
const weekDiff = Math.floor(diff / 7);
|
||||
return weekDiff % interval === 0 || (weekDiff + 1) % interval === 0;
|
||||
}
|
||||
if (target.getDay() !== baseDate.getDay()) return false;
|
||||
const weekDiff = Math.round(diff / 7);
|
||||
return weekDiff % interval === 0 && (!count || weekDiff < count);
|
||||
}
|
||||
if (freq === 'MONTHLY') {
|
||||
if (target.getDate() !== baseDate.getDate()) return false;
|
||||
const monthDiff = (target.getFullYear() - baseDate.getFullYear()) * 12 + target.getMonth() - baseDate.getMonth();
|
||||
return monthDiff > 0 && monthDiff % interval === 0 && (!count || monthDiff / interval < count);
|
||||
}
|
||||
if (freq === 'YEARLY') {
|
||||
if (target.getMonth() !== baseDate.getMonth() || target.getDate() !== baseDate.getDate()) return false;
|
||||
const yearDiff = target.getFullYear() - baseDate.getFullYear();
|
||||
return yearDiff > 0 && yearDiff % interval === 0 && (!count || yearDiff / interval < count);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getCardsForDate(ds) {
|
||||
const results = [];
|
||||
const targetYear = parseInt(ds.substring(0, 4));
|
||||
for (const c of state.cards) {
|
||||
if (cardBaseDate(c) === ds) {
|
||||
results.push({ ...c, _recurring: false });
|
||||
} else if (c.caldav_rrule && matchesRRule(c, ds)) {
|
||||
const baseYear = parseInt(cardBaseDate(c).substring(0, 4));
|
||||
const rrule = parseRRule(c.caldav_rrule);
|
||||
const age = rrule?.FREQ === 'YEARLY' ? targetYear - baseYear : null;
|
||||
results.push({ ...c, _recurring: true, _age: age });
|
||||
} else if ((c.tags || '').split(',').includes('geburtstag')) {
|
||||
// Local birthday cards repeat yearly without RRULE
|
||||
const base = cardBaseDate(c);
|
||||
if (base.substring(5) === ds.substring(5) && ds !== base) {
|
||||
const age = targetYear - parseInt(base.substring(0, 4));
|
||||
results.push({ ...c, _recurring: true, _age: age > 0 ? age : null });
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function getCardCalConfig(card) {
|
||||
const tags = (card.tags || '').split(',');
|
||||
// Find a tag that matches a calendar config (skip termin/geburtstag)
|
||||
for (const t of tags) {
|
||||
if (t && t !== 'termin' && t !== 'geburtstag') {
|
||||
const cfg = getCalConfig(t);
|
||||
if (cfg) return cfg;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isJournal(card) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}/.test(card.title)) return false;
|
||||
// Appointments (termin/geburtstag/caldav) are NOT journals
|
||||
const tags = (card.tags || '').split(',');
|
||||
if (tags.includes('termin') || tags.includes('geburtstag')) return false;
|
||||
if (card.caldav_uid) return false;
|
||||
// "YYYY-MM-DD — Something" is an appointment, pure "YYYY-MM-DD" is journal
|
||||
if (/^\d{4}-\d{2}-\d{2}\s*[—-]\s*.+/.test(card.title)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isAppointment(card) {
|
||||
const tags = (card.tags || '').split(',');
|
||||
return tags.includes('termin') || card.caldav_uid || /^\d{4}-\d{2}-\d{2}\s*[—-]\s*.+/.test(card.title);
|
||||
}
|
||||
|
||||
function render() {
|
||||
title.textContent = `${MONTHS[viewMonth]} ${viewYear}`;
|
||||
@@ -68,12 +176,41 @@ function render() {
|
||||
}
|
||||
const isToday = ds === todayStr;
|
||||
const cards = getCardsForDate(ds);
|
||||
const journals = cards.filter(isJournal);
|
||||
const others = cards.filter(c => !isJournal(c));
|
||||
const isBday = c => (c._recurring && c._age != null) || (c.tags || '').split(',').includes('geburtstag');
|
||||
const birthdays = cards.filter(isBday);
|
||||
const appointments = cards.filter(c => !isBday(c) && isAppointment(c));
|
||||
const journals = cards.filter(c => !isBday(c) && !isAppointment(c) && isJournal(c));
|
||||
const others = cards.filter(c => !isBday(c) && !isAppointment(c) && !isJournal(c));
|
||||
|
||||
html += `<div class="cal-cell ${inMonth ? '' : 'cal-cell-out'} ${isToday ? 'cal-cell-today' : ''}" data-date="${ds}">`;
|
||||
html += `<div class="cal-cell-num ${isToday ? 'text-t-accent' : ''}">${displayNum}</div>`;
|
||||
html += '<div class="cal-cell-items">';
|
||||
// Birthdays: red, cake icon, name only
|
||||
for (const c of birthdays) {
|
||||
const name = (c.title || '').replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title;
|
||||
const age = c._age != null ? c._age : (parseInt(ds.substring(0, 4)) - parseInt(cardBaseDate(c).substring(0, 4))) || null;
|
||||
html += `<div class="cal-item" data-card-id="${c.id}" style="border-left-color:#fb7185;color:#fb7185;">`;
|
||||
html += `<i class="ph ph-cake" style="font-size:9px;flex-shrink:0;"></i>`;
|
||||
html += `<span class="truncate">${esc(name)}</span>`;
|
||||
if (age) html += `<span style="font-family:var(--t-font-pixel);font-size:8px;flex-shrink:0;opacity:0.7;">${age}</span>`;
|
||||
html += `</div>`;
|
||||
}
|
||||
// Appointments: show event name + optional time, with calendar color/icon
|
||||
for (const c of appointments) {
|
||||
const name = (c.title || '').replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title;
|
||||
const timeMatch = (c.body || '').match(/\*\*Zeit:\*\*\s*(\d{1,2}:\d{2})/);
|
||||
const timeStr = timeMatch ? timeMatch[1] : '';
|
||||
const calCfg = getCardCalConfig(c);
|
||||
const itemColor = calCfg?.color || c.color;
|
||||
const itemIcon = calCfg?.icon || null;
|
||||
html += `<div class="cal-item" data-card-id="${c.id}" style="border-left-color:${itemColor};">`;
|
||||
if (itemIcon) html += `<i class="ph ph-${itemIcon}" style="font-size:9px;color:${itemColor};flex-shrink:0;"></i>`;
|
||||
if (timeStr) html += `<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-dim);flex-shrink:0;">${timeStr}</span>`;
|
||||
if (c._recurring && !itemIcon) html += `<i class="ph ph-arrows-clockwise" style="font-size:8px;color:var(--t-dim);flex-shrink:0;"></i>`;
|
||||
html += `<span class="truncate">${esc(name)}</span>`;
|
||||
html += `</div>`;
|
||||
}
|
||||
// Journals
|
||||
for (const c of journals) {
|
||||
const body = stripMd(c.body || '').substring(0, 60);
|
||||
const todos = getTodos(c.body);
|
||||
@@ -81,9 +218,14 @@ function render() {
|
||||
if (todos) html += `<span class="cal-todo">${todos.done}/${todos.total}</span>`;
|
||||
html += `<span class="truncate">${esc(body || '(empty)')}</span></div>`;
|
||||
}
|
||||
// Other cards
|
||||
for (const c of others) {
|
||||
html += `<div class="cal-item" data-card-id="${c.id}" style="border-left-color:${c.color}">`;
|
||||
html += `<span class="truncate">${esc(c.title)}</span></div>`;
|
||||
if (c._recurring) html += `<i class="ph ph-arrows-clockwise" style="font-size:9px;color:var(--t-dim);flex-shrink:0;"></i>`;
|
||||
else if (c.caldav_uid) html += `<i class="ph ph-cloud-check" style="font-size:9px;color:var(--t-dim);flex-shrink:0;"></i>`;
|
||||
const label = c._recurring ? (c.title || '').replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title : c.title;
|
||||
html += `<span class="truncate">${esc(label)}</span>`;
|
||||
html += `</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
@@ -92,13 +234,34 @@ function render() {
|
||||
|
||||
grid.innerHTML = html;
|
||||
|
||||
// Click on card item → open card
|
||||
// Click on card item → open card (hide calendar, restore on return)
|
||||
grid.querySelectorAll('[data-card-id]').forEach(el => {
|
||||
let clickTimer = null;
|
||||
el.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
if (clickTimer) return;
|
||||
clickTimer = setTimeout(() => {
|
||||
clickTimer = null;
|
||||
closeDayPopup();
|
||||
state._returnToCalendar = true;
|
||||
overlay.style.visibility = 'hidden';
|
||||
window._openDetail(el.dataset.cardId);
|
||||
}, 250);
|
||||
});
|
||||
el.addEventListener('dblclick', e => {
|
||||
e.stopPropagation();
|
||||
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
|
||||
closeDayPopup();
|
||||
state._returnToCalendar = true;
|
||||
window._openDetail(el.dataset.cardId);
|
||||
closeCalendar();
|
||||
const card = state.cards.find(c => c.id == el.dataset.cardId);
|
||||
if (card) window._openModal(card);
|
||||
});
|
||||
el.addEventListener('contextmenu', e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const card = state.cards.find(c => c.id == el.dataset.cardId);
|
||||
if (card) window._showContextMenu(e.clientX, e.clientY, card);
|
||||
});
|
||||
});
|
||||
// Click on cell → open day popup
|
||||
@@ -137,12 +300,12 @@ function openDayPopup(ds, cellEl) {
|
||||
position: 'fixed', zIndex: '200',
|
||||
background: 'var(--t-surface)', border: '1px solid var(--t-border)',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.4)', borderRadius: '4px',
|
||||
minWidth: '240px', maxWidth: '360px', maxHeight: '320px',
|
||||
minWidth: '360px', maxWidth: '520px', maxHeight: '320px',
|
||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
});
|
||||
|
||||
// Position near the cell
|
||||
const x = Math.min(rect.left + rect.width / 2 - 120, window.innerWidth - 370);
|
||||
const x = Math.min(rect.left + rect.width / 2 - 180, window.innerWidth - 530);
|
||||
const y = rect.bottom + 4 > window.innerHeight - 100 ? rect.top - 4 : rect.bottom + 4;
|
||||
dayPopup.style.left = Math.max(8, x) + 'px';
|
||||
if (rect.bottom + 4 > window.innerHeight - 100) {
|
||||
@@ -154,10 +317,20 @@ function openDayPopup(ds, cellEl) {
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'padding:8px 12px;border-bottom:1px solid var(--t-border);display:flex;align-items:center;gap:8px;flex-shrink:0;';
|
||||
const allCfg = getAllCalConfigs();
|
||||
const calSlugs = Object.keys(allCfg).filter(s => s !== 'geburtstage' && s !== 'birthday');
|
||||
const calOptions = `<option value="" style="color:var(--t-muted)">standard</option>` +
|
||||
calSlugs.map(slug => {
|
||||
const cfg = allCfg[slug];
|
||||
return `<option value="${slug}" style="color:${cfg.color || '#fb923c'}">${slug}</option>`;
|
||||
}).join('');
|
||||
|
||||
header.innerHTML = `
|
||||
<span style="font-family:var(--t-font-pixel);font-size:11px;color:var(--t-accent);">${ds}</span>
|
||||
<span style="font-family:var(--t-font-pixel);font-size:9px;color:var(--t-dim);">${cards.length} card${cards.length !== 1 ? 's' : ''}</span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="day-popup-new" data-type="birthday" style="font-family:var(--t-font-pixel);font-size:9px;color:#fb7185;background:none;border:1px solid var(--t-border);padding:2px 8px;cursor:pointer;border-radius:2px;"><i class="ph ph-cake" style="font-size:9px;"></i> geb.</button>
|
||||
<select id="day-popup-cal" style="font-family:var(--t-font-pixel);font-size:9px;color:var(--t-muted);background:var(--t-bg);border:1px solid var(--t-border);padding:2px 4px;border-radius:2px;cursor:pointer;">${calOptions}</select>
|
||||
<button class="day-popup-new" data-type="appointment" style="font-family:var(--t-font-pixel);font-size:9px;color:var(--t-muted);background:none;border:1px solid var(--t-border);padding:2px 8px;cursor:pointer;border-radius:2px;">+ termin</button>
|
||||
<button class="day-popup-new" data-type="journal" style="font-family:var(--t-font-pixel);font-size:9px;color:var(--t-muted);background:none;border:1px solid var(--t-border);padding:2px 8px;cursor:pointer;border-radius:2px;">+ journal</button>`;
|
||||
dayPopup.appendChild(header);
|
||||
@@ -167,12 +340,18 @@ function openDayPopup(ds, cellEl) {
|
||||
list.style.cssText = 'overflow-y:auto;flex:1;padding:4px 0;';
|
||||
if (cards.length) {
|
||||
list.innerHTML = cards.map(c => {
|
||||
const isJ = isJournal(c);
|
||||
const label = isJ ? (stripMd(c.body || '').substring(0, 80) || '(empty journal)') : c.title;
|
||||
const isBday = c._recurring && c._age != null;
|
||||
const isJ = isJournal(c) && !c._recurring;
|
||||
const name = (c.title || '').replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title;
|
||||
const popupLabel = isBday ? name : c._recurring ? name : isJ ? (stripMd(c.body || '').substring(0, 80) || '(empty journal)') : c.title;
|
||||
const todos = getTodos(c.body);
|
||||
return `<div class="day-popup-card" data-card-id="${c.id}" style="padding:6px 12px;cursor:pointer;display:flex;align-items:center;gap:8px;transition:background 0.1s;">
|
||||
<span style="width:8px;height:8px;border-radius:50%;flex-shrink:0;background:${c.color}"></span>
|
||||
<span style="font-size:11px;color:var(--t-text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(label)}</span>
|
||||
const dotColor = isBday ? '#fb7185' : c.color;
|
||||
return `<div class="day-popup-card" data-card-id="${c.id}" style="padding:6px 12px;cursor:pointer;display:flex;align-items:center;gap:8px;transition:background 0.1s;${isBday ? 'color:#fb7185;' : ''}">
|
||||
<span style="width:8px;height:8px;border-radius:50%;flex-shrink:0;background:${dotColor}"></span>
|
||||
${isBday ? '<i class="ph ph-cake" style="font-size:11px;flex-shrink:0;"></i>' : ''}
|
||||
<span style="font-size:11px;${isBday ? '' : 'color:var(--t-text);'}flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(popupLabel)}</span>
|
||||
${isBday ? `<span style="font-family:var(--t-font-pixel);font-size:8px;opacity:0.7;">${c._age}</span>` : ''}
|
||||
${c._recurring && !isBday ? '<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-dim);">recurring</span>' : ''}
|
||||
${isJ ? '<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-dim);">journal</span>' : ''}
|
||||
${todos ? `<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-accent);">${todos.done}/${todos.total}</span>` : ''}
|
||||
</div>`;
|
||||
@@ -186,12 +365,32 @@ function openDayPopup(ds, cellEl) {
|
||||
|
||||
// Hover effect
|
||||
dayPopup.querySelectorAll('.day-popup-card').forEach(el => {
|
||||
let clickTimer = null;
|
||||
el.addEventListener('mouseenter', () => { el.style.background = 'var(--t-raised)'; });
|
||||
el.addEventListener('mouseleave', () => { el.style.background = ''; });
|
||||
el.addEventListener('click', () => {
|
||||
if (clickTimer) return;
|
||||
clickTimer = setTimeout(() => {
|
||||
clickTimer = null;
|
||||
closeDayPopup();
|
||||
state._returnToCalendar = true;
|
||||
overlay.style.visibility = 'hidden';
|
||||
window._openDetail(el.dataset.cardId);
|
||||
}, 250);
|
||||
});
|
||||
el.addEventListener('dblclick', () => {
|
||||
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
|
||||
closeDayPopup();
|
||||
state._returnToCalendar = true;
|
||||
window._openDetail(el.dataset.cardId);
|
||||
closeCalendar();
|
||||
const card = state.cards.find(c => c.id == el.dataset.cardId);
|
||||
if (card) window._openModal(card);
|
||||
});
|
||||
el.addEventListener('contextmenu', e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const card = state.cards.find(c => c.id == el.dataset.cardId);
|
||||
if (card) window._showContextMenu(e.clientX, e.clientY, card);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,6 +398,9 @@ function openDayPopup(ds, cellEl) {
|
||||
dayPopup.querySelectorAll('.day-popup-new').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const type = btn.dataset.type;
|
||||
// Read select before closing popup
|
||||
const select = dayPopup?.querySelector('#day-popup-cal');
|
||||
const selectedCal = select?.value || '';
|
||||
closeDayPopup();
|
||||
state._returnToCalendar = true;
|
||||
closeCalendar();
|
||||
@@ -210,8 +412,15 @@ function openDayPopup(ds, cellEl) {
|
||||
}
|
||||
window._openModal({ id: null, title: ds, body: '', color: '#38bdf8', bucket_id: null });
|
||||
} else if (type === 'appointment') {
|
||||
const body = `**Zeit:** \n**Ort:** \n**Teilnehmer:** \n\n---\n\n**Notizen:**\n\n\n\n#termin`;
|
||||
window._openModal({ id: null, title: `${ds} — `, body, color: '#fb923c', bucket_id: null });
|
||||
const calSlug = selectedCal;
|
||||
const cfg = calSlug ? getCalConfig(calSlug) : null;
|
||||
const col = cfg?.color || '#fb923c';
|
||||
const calTag = calSlug ? ` #${calSlug}` : '';
|
||||
const body = `**Zeit:** \n**Ort:** \n**Teilnehmer:** \n\n---\n\n**Notizen:**\n\n\n\n#termin${calTag}`;
|
||||
window._openModal({ id: null, title: `${ds} — `, body, color: col, bucket_id: null });
|
||||
} else if (type === 'birthday') {
|
||||
const body = `#geburtstag`;
|
||||
window._openModal({ id: null, title: `${ds} — `, body, color: '#fb7185', bucket_id: null });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -228,12 +437,18 @@ function openDayPopup(ds, cellEl) {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function refreshCalendar() {
|
||||
if (overlay?.classList.contains('open')) render();
|
||||
}
|
||||
|
||||
export function openCalendar() {
|
||||
const now = new Date();
|
||||
viewYear = now.getFullYear();
|
||||
viewMonth = now.getMonth();
|
||||
render();
|
||||
overlay.classList.add('open');
|
||||
overlay.style.visibility = '';
|
||||
autoSync();
|
||||
}
|
||||
|
||||
export function closeCalendar() {
|
||||
|
||||
@@ -169,7 +169,7 @@ export function showContextMenu(x, y, card) {
|
||||
if (ok) { const ch = getChildren(card.id); pushBatch('dissolve', ch.map(c => ({ cardId: c.id, prevData: { parent_id: card.id } }))); await Promise.all(ch.map(c => api(`api/cards/${c.id}`, { method: 'PATCH', body: { parent_id: null } }))); await window._reloadAll(); }
|
||||
}
|
||||
else if (a === 'ungroup') { hideContextMenu(); pushCardEdit('ungroup', card.id, { parent_id: card.parent_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }
|
||||
else if (a === 'delete') { hideContextMenu(); const ok = await showDialog({ title: 'delete_card', message: `"${card.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' }); if (ok) { pushCardDelete('delete', snapshotCard(card)); await api(`api/cards/${card.id}`, { method: 'DELETE' }); await window._reloadAll(); } }
|
||||
else if (a === 'delete') { hideContextMenu(); const ok = await showDialog({ title: 'delete_card', message: `"${card.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' }); if (ok) { const wasSync = !!card.caldav_uid; pushCardDelete('delete', snapshotCard(card)); await api(`api/cards/${card.id}`, { method: 'DELETE' }); await window._reloadAll(); if (wasSync) window._triggerSync?.(); } }
|
||||
}));
|
||||
|
||||
grid.querySelectorAll('[data-ctx-color]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); pushCardEdit('color', card.id, { color: card.color }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { color: btn.dataset.ctxColor } }); await window._reloadAll(); }));
|
||||
|
||||
@@ -950,11 +950,18 @@ $('detail-edit')?.addEventListener('click', () => {
|
||||
if (!state.viewingCard) return;
|
||||
const card = state.viewingCard;
|
||||
const fromGallery = state.openedFromGallery;
|
||||
const fromCalendar = state._returnToCalendar;
|
||||
state.openedFromSearch = false;
|
||||
state.openedFromGallery = false;
|
||||
state.galleryOrigin = null;
|
||||
state.returnToGalleryAfterEdit = fromGallery;
|
||||
state._returnToCalendar = false;
|
||||
closeDetail({ animate: false, restoreGallery: false });
|
||||
// Hide calendar so modal is visible
|
||||
if (fromCalendar) {
|
||||
$('calendar')?.classList.remove('open');
|
||||
state._returnToCalendar = true;
|
||||
}
|
||||
$('gallery')?.classList.remove('open');
|
||||
window._closeSearch();
|
||||
window._openModal(card);
|
||||
|
||||
@@ -340,6 +340,10 @@ async function doSave(close, { animate = true } = {}) {
|
||||
}
|
||||
}
|
||||
await window._reloadAll();
|
||||
// Auto-sync if card has #termin or #geburtstag
|
||||
if (data.body && /#(termin|geburtstag)\b/.test(data.body)) {
|
||||
window._triggerSync?.();
|
||||
}
|
||||
if (close) {
|
||||
closeModal({ animate });
|
||||
if (returnToGallery) window._openGallery?.();
|
||||
@@ -415,7 +419,7 @@ $('toggle-preview')?.addEventListener('click', () => {
|
||||
if (state.previewVisible) {
|
||||
applyEditorSplit();
|
||||
} else {
|
||||
if (editorPane) editorPane.style.flex = '';
|
||||
if (editorPane) editorPane.style.flex = '1 1 0%';
|
||||
preview.style.flex = '';
|
||||
}
|
||||
updatePreview();
|
||||
|
||||
@@ -7,6 +7,8 @@ export const state = {
|
||||
buckets: [],
|
||||
activeBucket: 'all',
|
||||
activeTags: [],
|
||||
hideTermine: localStorage.getItem('hyprcrd-hide-termine') === '1',
|
||||
superView: localStorage.getItem('hyprcrd-superview') === '1',
|
||||
activeGroup: null,
|
||||
sortMode: localStorage.getItem('hyprcrd-sort') || 'manual',
|
||||
editingId: null,
|
||||
@@ -42,11 +44,17 @@ export function tagChipHtml(tag) {
|
||||
return `<span class="tag-chip">${segs.join('<span class="tag-sep">/</span>')}</span>`;
|
||||
}
|
||||
|
||||
function effectiveDate(c) {
|
||||
const m = (c.title || '').match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
if (m) return m[1];
|
||||
return (c.created_at || '').substring(0, 10);
|
||||
}
|
||||
|
||||
export function sortedCards(cards) {
|
||||
const arr = [...cards];
|
||||
switch (state.sortMode) {
|
||||
case 'newest': return arr.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
|
||||
case 'oldest': return arr.sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''));
|
||||
case 'newest': return arr.sort((a, b) => effectiveDate(b).localeCompare(effectiveDate(a)));
|
||||
case 'oldest': return arr.sort((a, b) => effectiveDate(a).localeCompare(effectiveDate(b)));
|
||||
case 'alpha': return arr.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
||||
case 'tag': return arr.sort((a, b) => {
|
||||
const at = (a.tags || '').split(',')[0] || '\uffff';
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
class CalDavClient
|
||||
{
|
||||
private string $baseUrl;
|
||||
private string $username;
|
||||
private string $password;
|
||||
|
||||
public function __construct(string $baseUrl, string $username, string $password)
|
||||
{
|
||||
$this->baseUrl = rtrim($baseUrl, '/') . '/';
|
||||
$this->username = $username;
|
||||
$this->password = $password;
|
||||
}
|
||||
|
||||
public function getBaseUrl(): string { return $this->baseUrl; }
|
||||
|
||||
public function request(string $method, string $url, string $body = '', array $headers = []): array
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_USERPWD => $this->username . ':' . $this->password,
|
||||
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
]);
|
||||
if ($body !== '') {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
// Use caller's Content-Type if provided, otherwise default to XML
|
||||
$hasContentType = false;
|
||||
foreach ($headers as $h) { if (stripos($h, 'Content-Type:') === 0) { $hasContentType = true; break; } }
|
||||
$allHeaders = $hasContentType ? $headers : array_merge(['Content-Type: application/xml; charset=utf-8'], $headers);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $allHeaders);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$error = curl_error($ch);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
return ['status' => 0, 'headers' => '', 'body' => '', 'error' => $error];
|
||||
}
|
||||
|
||||
$respHeaders = substr($response, 0, $headerSize);
|
||||
$respBody = substr($response, $headerSize);
|
||||
|
||||
// Extract ETag from headers
|
||||
$etag = null;
|
||||
if (preg_match('/^ETag:\s*(.+)$/mi', $respHeaders, $m)) {
|
||||
$etag = trim($m[1]);
|
||||
}
|
||||
|
||||
return ['status' => $status, 'headers' => $respHeaders, 'body' => $respBody, 'etag' => $etag, 'error' => null];
|
||||
}
|
||||
|
||||
public function testConnection(): bool
|
||||
{
|
||||
$res = $this->request('PROPFIND', $this->baseUrl, '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>', ['Depth: 0']);
|
||||
return $res['status'] === 207;
|
||||
}
|
||||
|
||||
public function listCalendars(): array
|
||||
{
|
||||
// PROPFIND depth 1 on the base URL to find calendar collections underneath
|
||||
$body = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:displayname/><d:resourcetype/><cs:getctag/></d:prop></d:propfind>';
|
||||
$res = $this->request('PROPFIND', $this->baseUrl, $body, ['Depth: 1']);
|
||||
if ($res['status'] !== 207) return [];
|
||||
|
||||
$baseHref = rtrim(parse_url($this->baseUrl, PHP_URL_PATH), '/');
|
||||
$calendars = [];
|
||||
try {
|
||||
$xml = new SimpleXMLElement($res['body']);
|
||||
$xml->registerXPathNamespace('d', 'DAV:');
|
||||
$xml->registerXPathNamespace('cs', 'http://calendarserver.org/ns/');
|
||||
$xml->registerXPathNamespace('c', 'urn:ietf:params:xml:ns:caldav');
|
||||
foreach ($xml->xpath('//d:response') as $resp) {
|
||||
$resp->registerXPathNamespace('d', 'DAV:');
|
||||
$resp->registerXPathNamespace('cs', 'http://calendarserver.org/ns/');
|
||||
$resp->registerXPathNamespace('c', 'urn:ietf:params:xml:ns:caldav');
|
||||
|
||||
$href = (string) ($resp->xpath('.//d:href')[0] ?? '');
|
||||
// Skip the collection itself (same path as base)
|
||||
if (rtrim($href, '/') === $baseHref) continue;
|
||||
|
||||
// Check for calendar resource type via namespace-aware xpath
|
||||
$isCal = $resp->xpath('.//d:resourcetype/c:calendar');
|
||||
if (!$isCal) {
|
||||
// Fallback: check raw XML for 'calendar' string
|
||||
$rtXml = $resp->xpath('.//d:resourcetype');
|
||||
if (!$rtXml || strpos($rtXml[0]->asXML(), 'calendar') === false) continue;
|
||||
}
|
||||
|
||||
$name = (string) ($resp->xpath('.//d:displayname')[0] ?? basename(rtrim($href, '/')));
|
||||
$ctag = (string) ($resp->xpath('.//cs:getctag')[0] ?? '');
|
||||
if ($href) $calendars[] = ['name' => $name, 'href' => $href, 'ctag' => $ctag];
|
||||
}
|
||||
} catch (Exception $e) {}
|
||||
return $calendars;
|
||||
}
|
||||
|
||||
public function getCalendarCtag(): ?string
|
||||
{
|
||||
$body = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"><d:prop><cs:getctag/></d:prop></d:propfind>';
|
||||
$res = $this->request('PROPFIND', $this->baseUrl, $body, ['Depth: 0']);
|
||||
if ($res['status'] !== 207) return null;
|
||||
try {
|
||||
$xml = new SimpleXMLElement($res['body']);
|
||||
$xml->registerXPathNamespace('d', 'DAV:');
|
||||
$xml->registerXPathNamespace('cs', 'http://calendarserver.org/ns/');
|
||||
$ctag = $xml->xpath('//cs:getctag');
|
||||
return $ctag ? (string) $ctag[0] : null;
|
||||
} catch (Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function listEvents(): array
|
||||
{
|
||||
$body = '<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"/></c:comp-filter></c:filter></c:calendar-query>';
|
||||
$res = $this->request('REPORT', $this->baseUrl, $body, ['Depth: 1']);
|
||||
if ($res['status'] !== 207) return [];
|
||||
|
||||
$events = [];
|
||||
try {
|
||||
$xml = new SimpleXMLElement($res['body']);
|
||||
$xml->registerXPathNamespace('d', 'DAV:');
|
||||
$xml->registerXPathNamespace('c', 'urn:ietf:params:xml:ns:caldav');
|
||||
foreach ($xml->xpath('//d:response') as $resp) {
|
||||
$resp->registerXPathNamespace('d', 'DAV:');
|
||||
$resp->registerXPathNamespace('c', 'urn:ietf:params:xml:ns:caldav');
|
||||
$href = (string) ($resp->xpath('.//d:href')[0] ?? '');
|
||||
$etag = (string) ($resp->xpath('.//d:getetag')[0] ?? '');
|
||||
$ical = (string) ($resp->xpath('.//c:calendar-data')[0] ?? '');
|
||||
if ($href && $ical) {
|
||||
$events[] = ['href' => $href, 'etag' => $etag, 'ical' => $ical];
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {}
|
||||
return $events;
|
||||
}
|
||||
|
||||
public function createEvent(string $uid, string $icalData): array
|
||||
{
|
||||
// Use URL-safe filename (replace @ and other special chars)
|
||||
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $uid);
|
||||
$url = $this->baseUrl . $filename . '.ics';
|
||||
return $this->request('PUT', $url, $icalData, [
|
||||
'Content-Type: text/calendar; charset=utf-8',
|
||||
'If-None-Match: *',
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateEvent(string $href, string $icalData, string $etag): array
|
||||
{
|
||||
$url = $this->resolveHref($href);
|
||||
return $this->request('PUT', $url, $icalData, [
|
||||
'Content-Type: text/calendar; charset=utf-8',
|
||||
'If-Match: ' . $etag,
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteEvent(string $href, string $etag): array
|
||||
{
|
||||
$url = $this->resolveHref($href);
|
||||
return $this->request('DELETE', $url, '', ['If-Match: ' . $etag]);
|
||||
}
|
||||
|
||||
private function resolveHref(string $href): string
|
||||
{
|
||||
if (str_starts_with($href, 'http')) return $href;
|
||||
$parsed = parse_url($this->baseUrl);
|
||||
return $parsed['scheme'] . '://' . $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : '') . $href;
|
||||
}
|
||||
|
||||
// ── iCalendar parsing ──
|
||||
|
||||
public static function parseVEvent(string $ical): ?array
|
||||
{
|
||||
// Unfold RFC 5545 line continuations
|
||||
$ical = preg_replace('/\r\n[ \t]/', '', $ical);
|
||||
$ical = str_replace("\r\n", "\n", $ical);
|
||||
|
||||
if (!preg_match('/BEGIN:VEVENT(.+?)END:VEVENT/s', $ical, $m)) return null;
|
||||
$block = $m[1];
|
||||
|
||||
$get = function ($prop) use ($block) {
|
||||
if (preg_match('/^' . preg_quote($prop, '/') . '[^:]*:(.+)$/m', $block, $m)) return trim($m[1]);
|
||||
return null;
|
||||
};
|
||||
|
||||
return [
|
||||
'uid' => $get('UID'),
|
||||
'summary' => $get('SUMMARY'),
|
||||
'dtstart' => $get('DTSTART'),
|
||||
'dtend' => $get('DTEND'),
|
||||
'location' => $get('LOCATION'),
|
||||
'description' => str_replace('\\n', "\n", $get('DESCRIPTION') ?? ''),
|
||||
'last_modified' => $get('LAST-MODIFIED'),
|
||||
'sequence' => (int) ($get('SEQUENCE') ?? 0),
|
||||
'rrule' => $get('RRULE'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function buildVEvent(array $props): string
|
||||
{
|
||||
$fold = function ($line) {
|
||||
$out = '';
|
||||
while (strlen($line) > 75) {
|
||||
$out .= substr($line, 0, 75) . "\r\n ";
|
||||
$line = substr($line, 75);
|
||||
}
|
||||
return $out . $line;
|
||||
};
|
||||
|
||||
$escape = function ($s) {
|
||||
return str_replace(["\n", ",", ";", "\\"], ["\\n", "\\,", "\\;", "\\\\"], $s ?? '');
|
||||
};
|
||||
|
||||
$lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//hyprcrd//CalDAV Client//EN',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'BEGIN:VEVENT',
|
||||
'UID:' . $props['uid'],
|
||||
];
|
||||
|
||||
// DTSTART / DTEND
|
||||
if (strlen($props['dtstart'] ?? '') === 8) {
|
||||
$lines[] = 'DTSTART;VALUE=DATE:' . $props['dtstart'];
|
||||
$lines[] = 'DTEND;VALUE=DATE:' . ($props['dtend'] ?? $props['dtstart']);
|
||||
} else {
|
||||
$lines[] = 'DTSTART:' . $props['dtstart'];
|
||||
if (!empty($props['dtend'])) $lines[] = 'DTEND:' . $props['dtend'];
|
||||
}
|
||||
|
||||
$lines[] = $fold('SUMMARY:' . $escape($props['summary'] ?? ''));
|
||||
if (!empty($props['rrule'])) $lines[] = 'RRULE:' . $props['rrule'];
|
||||
if (!empty($props['location'])) $lines[] = $fold('LOCATION:' . $escape($props['location']));
|
||||
if (!empty($props['description'])) $lines[] = $fold('DESCRIPTION:' . $escape($props['description']));
|
||||
$lines[] = 'SEQUENCE:' . ($props['sequence'] ?? 0);
|
||||
$lines[] = 'LAST-MODIFIED:' . gmdate('Ymd\THis\Z');
|
||||
$lines[] = 'DTSTAMP:' . gmdate('Ymd\THis\Z');
|
||||
if (!empty($props['color'])) $lines[] = 'X-HYPRCRD-COLOR:' . $props['color'];
|
||||
$lines[] = 'END:VEVENT';
|
||||
$lines[] = 'END:VCALENDAR';
|
||||
|
||||
return implode("\r\n", $lines) . "\r\n";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/CalDavClient.php';
|
||||
|
||||
class CalDavSync
|
||||
{
|
||||
private PDO $db;
|
||||
private ?CalDavClient $client = null;
|
||||
private array $log = [];
|
||||
private array $stats = ['pushed' => 0, 'pulled' => 0, 'deleted' => 0, 'conflicts' => 0];
|
||||
|
||||
public function __construct(PDO $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
// ── Encryption helpers ──
|
||||
|
||||
private function getEncKey(): string
|
||||
{
|
||||
$hash = $this->db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
|
||||
return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
|
||||
}
|
||||
|
||||
private function encrypt(string $plain): string
|
||||
{
|
||||
$key = $this->getEncKey();
|
||||
$iv = random_bytes(16);
|
||||
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, 0, $iv);
|
||||
return base64_encode($iv . base64_decode($cipher));
|
||||
}
|
||||
|
||||
private function decrypt(string $encrypted): string
|
||||
{
|
||||
$key = $this->getEncKey();
|
||||
$raw = base64_decode($encrypted);
|
||||
$iv = substr($raw, 0, 16);
|
||||
$cipher = base64_encode(substr($raw, 16));
|
||||
return openssl_decrypt($cipher, 'aes-256-cbc', $key, 0, $iv) ?: '';
|
||||
}
|
||||
|
||||
// ── Settings ──
|
||||
|
||||
public function getSetting(string $key): ?string
|
||||
{
|
||||
return $this->db->query("SELECT value FROM settings WHERE key = 'caldav_{$key}'")->fetchColumn() ?: null;
|
||||
}
|
||||
|
||||
public function setSetting(string $key, ?string $value): void
|
||||
{
|
||||
$this->db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (:k, :v)")
|
||||
->execute(['k' => "caldav_{$key}", 'v' => $value ?? '']);
|
||||
}
|
||||
|
||||
public function getSettings(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->getSetting('enabled') === '1',
|
||||
'url' => $this->getSetting('url') ? $this->decrypt($this->getSetting('url')) : '',
|
||||
'username' => $this->getSetting('username') ? $this->decrypt($this->getSetting('username')) : '',
|
||||
'has_password' => !empty($this->getSetting('password')),
|
||||
'calendar' => $this->getSetting('calendar') ?? '',
|
||||
'last_sync' => $this->getSetting('last_sync') ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
public function saveSettings(array $input): void
|
||||
{
|
||||
if (isset($input['url'])) $this->setSetting('url', $this->encrypt($input['url']));
|
||||
if (isset($input['username'])) $this->setSetting('username', $this->encrypt($input['username']));
|
||||
if (!empty($input['password'])) $this->setSetting('password', $this->encrypt($input['password']));
|
||||
if (isset($input['calendar'])) $this->setSetting('calendar', $input['calendar']);
|
||||
if (isset($input['enabled'])) $this->setSetting('enabled', $input['enabled'] ? '1' : '0');
|
||||
}
|
||||
|
||||
// ── Client ──
|
||||
|
||||
private function getClient(?string $url = null, ?string $user = null, ?string $pass = null): CalDavClient
|
||||
{
|
||||
if (!$this->client) {
|
||||
$this->client = new CalDavClient(
|
||||
$url ?? ($this->getSetting('url') ? $this->decrypt($this->getSetting('url')) : ''),
|
||||
$user ?? ($this->getSetting('username') ? $this->decrypt($this->getSetting('username')) : ''),
|
||||
$pass ?? ($this->getSetting('password') ? $this->decrypt($this->getSetting('password')) : '')
|
||||
);
|
||||
}
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public function testConnection(?string $url = null, ?string $user = null, ?string $pass = null): array
|
||||
{
|
||||
$log = [];
|
||||
try {
|
||||
$resolvedUrl = $url ?? ($this->getSetting('url') ? $this->decrypt($this->getSetting('url')) : '');
|
||||
$resolvedUser = $user ?? ($this->getSetting('username') ? $this->decrypt($this->getSetting('username')) : '');
|
||||
$resolvedPass = $pass ?? ($this->getSetting('password') ? $this->decrypt($this->getSetting('password')) : '');
|
||||
|
||||
$log[] = 'URL: ' . preg_replace('#(https?://)[^@/]+:[^@/]+@#', '$1***:***@', $resolvedUrl);
|
||||
$log[] = 'User: ' . $resolvedUser;
|
||||
|
||||
if (!$resolvedUrl) { $log[] = 'ERROR: no URL configured'; return ['ok' => false, 'error' => 'no URL', 'log' => $log]; }
|
||||
if (!$resolvedUser) { $log[] = 'ERROR: no username'; return ['ok' => false, 'error' => 'no username', 'log' => $log]; }
|
||||
if (!$resolvedPass) { $log[] = 'ERROR: no password'; return ['ok' => false, 'error' => 'no password', 'log' => $log]; }
|
||||
|
||||
$client = new CalDavClient($resolvedUrl, $resolvedUser, $resolvedPass);
|
||||
|
||||
$log[] = 'PROPFIND depth 0...';
|
||||
$res = $client->request('PROPFIND', $resolvedUrl, '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>', ['Depth: 0']);
|
||||
$log[] = "HTTP {$res['status']}" . ($res['error'] ? " — {$res['error']}" : '');
|
||||
|
||||
if ($res['status'] === 0) {
|
||||
$log[] = 'ERROR: could not connect (check URL, network, SSL)';
|
||||
return ['ok' => false, 'error' => $res['error'] ?: 'connection failed', 'log' => $log];
|
||||
}
|
||||
if ($res['status'] === 401) {
|
||||
$log[] = 'ERROR: authentication failed (check username/password)';
|
||||
return ['ok' => false, 'error' => '401 unauthorized', 'log' => $log];
|
||||
}
|
||||
if ($res['status'] === 404) {
|
||||
$log[] = 'ERROR: calendar not found (check URL path)';
|
||||
return ['ok' => false, 'error' => '404 not found', 'log' => $log];
|
||||
}
|
||||
if ($res['status'] !== 207) {
|
||||
$log[] = 'ERROR: unexpected status ' . $res['status'];
|
||||
$log[] = substr($res['body'], 0, 300);
|
||||
return ['ok' => false, 'error' => "HTTP {$res['status']}", 'log' => $log];
|
||||
}
|
||||
|
||||
$log[] = 'OK — listing calendars (PROPFIND depth 1)...';
|
||||
$calendars = $client->listCalendars();
|
||||
$log[] = count($calendars) . ' calendar(s) found';
|
||||
foreach ($calendars as $c) $log[] = " - {$c['name']} ({$c['href']})";
|
||||
if (!$calendars) {
|
||||
// Debug: show raw PROPFIND response
|
||||
$debugRes = $client->request('PROPFIND', $resolvedUrl, '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:displayname/><d:resourcetype/></d:prop></d:propfind>', ['Depth: 1']);
|
||||
$log[] = 'DEBUG depth 1 status: ' . $debugRes['status'];
|
||||
// Count responses in XML
|
||||
$responseCount = substr_count($debugRes['body'], '<d:response');
|
||||
if (!$responseCount) $responseCount = substr_count($debugRes['body'], ':response');
|
||||
$log[] = "DEBUG responses in XML: {$responseCount}";
|
||||
$log[] = 'DEBUG body (first 500): ' . substr(preg_replace('/\s+/', ' ', $debugRes['body']), 0, 500);
|
||||
}
|
||||
|
||||
return ['ok' => true, 'calendars' => $calendars, 'log' => $log];
|
||||
} catch (Exception $e) {
|
||||
$log[] = 'EXCEPTION: ' . $e->getMessage();
|
||||
return ['ok' => false, 'error' => $e->getMessage(), 'log' => $log];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Field mapping ──
|
||||
|
||||
public function cardToVEvent(array $card): string
|
||||
{
|
||||
$title = $card['title'] ?? '';
|
||||
$body = $card['body'] ?? '';
|
||||
|
||||
// Parse title: "YYYY-MM-DD — Subject" or "YYYY-MM-DD"
|
||||
preg_match('/^(\d{4}-\d{2}-\d{2})\s*(?:—|-)\s*(.*)$/', $title, $tm);
|
||||
$date = $tm[1] ?? substr($title, 0, 10);
|
||||
$summary = trim($tm[2] ?? $title);
|
||||
|
||||
// Parse body fields
|
||||
$time = ''; $timeEnd = '';
|
||||
if (preg_match('/\*\*Zeit:\*\*\s*(\d{1,2}:\d{2})(?:\s*-\s*(\d{1,2}:\d{2}))?/i', $body, $zm)) {
|
||||
$time = $zm[1];
|
||||
$timeEnd = $zm[2] ?? '';
|
||||
}
|
||||
$location = '';
|
||||
if (preg_match('/\*\*Ort:\*\*\s*(.+)/i', $body, $lm)) $location = trim($lm[1]);
|
||||
$description = '';
|
||||
if (preg_match('/\*\*Notizen:\*\*\s*\n([\s\S]*?)(?:\n\n#|\z)/i', $body, $nm)) $description = trim($nm[1]);
|
||||
|
||||
// Build DTSTART/DTEND
|
||||
$dateCompact = str_replace('-', '', $date);
|
||||
if ($time) {
|
||||
$dtstart = $dateCompact . 'T' . str_replace(':', '', $time) . '00';
|
||||
if ($timeEnd) {
|
||||
$dtend = $dateCompact . 'T' . str_replace(':', '', $timeEnd) . '00';
|
||||
} else {
|
||||
// Default: +1 hour
|
||||
$ts = strtotime($date . ' ' . $time);
|
||||
$dtend = date('Ymd\THis', $ts + 3600);
|
||||
}
|
||||
} else {
|
||||
$dtstart = $dateCompact;
|
||||
// All-day: DTEND = next day
|
||||
$dtend = date('Ymd', strtotime($date . ' +1 day'));
|
||||
}
|
||||
|
||||
$uid = $card['caldav_uid'] ?: 'card-' . $card['id'] . '@hyprcrd';
|
||||
|
||||
// Detect birthday → add RRULE:FREQ=YEARLY
|
||||
$tags = array_map('trim', explode(',', $card['tags'] ?? ''));
|
||||
$isBirthday = in_array('geburtstag', $tags);
|
||||
$rrule = $card['caldav_rrule'] ?? ($isBirthday ? 'FREQ=YEARLY' : null);
|
||||
|
||||
return CalDavClient::buildVEvent([
|
||||
'uid' => $uid,
|
||||
'dtstart' => $dtstart,
|
||||
'dtend' => $dtend,
|
||||
'summary' => $summary,
|
||||
'location' => $location,
|
||||
'rrule' => $rrule,
|
||||
'description' => $description,
|
||||
'color' => $card['color'] ?? '#fb923c',
|
||||
'sequence' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function vEventToCard(array $vevent, ?array $existing = null, ?string $calendarName = null): array
|
||||
{
|
||||
$dt = $vevent['dtstart'] ?? '';
|
||||
if (preg_match('/^(\d{4})(\d{2})(\d{2})/', $dt, $dm)) {
|
||||
$date = "{$dm[1]}-{$dm[2]}-{$dm[3]}";
|
||||
} else {
|
||||
$date = date('Y-m-d');
|
||||
}
|
||||
|
||||
$time = '';
|
||||
$timeEnd = '';
|
||||
if (preg_match('/T(\d{2})(\d{2})/', $dt, $hm)) {
|
||||
$time = "{$hm[1]}:{$hm[2]}";
|
||||
}
|
||||
if (!empty($vevent['dtend']) && preg_match('/T(\d{2})(\d{2})/', $vevent['dtend'], $hm2)) {
|
||||
$timeEnd = "{$hm2[1]}:{$hm2[2]}";
|
||||
}
|
||||
|
||||
$summary = $vevent['summary'] ?? 'Termin';
|
||||
$title = $summary ? "{$date} — {$summary}" : $date;
|
||||
|
||||
// Determine tag from calendar name
|
||||
$isBirthdayCal = $calendarName && (stripos($calendarName, 'geburtstag') !== false || stripos($calendarName, 'birthday') !== false);
|
||||
$tag = $isBirthdayCal ? '#geburtstag' : '#termin';
|
||||
if ($calendarName && !$isBirthdayCal) {
|
||||
// Add calendar name as additional tag
|
||||
$calTag = strtolower(preg_replace('/[^a-zA-Z0-9_-]/', '', $calendarName));
|
||||
if ($calTag) $tag .= " #{$calTag}";
|
||||
}
|
||||
|
||||
$zeitLine = $time ? ($timeEnd ? "{$time}-{$timeEnd}" : $time) : '';
|
||||
$body = "**Zeit:** {$zeitLine}\n**Ort:** " . ($vevent['location'] ?? '') . "\n**Teilnehmer:** \n\n---\n\n**Notizen:**\n" . ($vevent['description'] ?? '') . "\n\n{$tag}";
|
||||
|
||||
// Color: birthdays pink, others orange
|
||||
$defaultColor = $isBirthdayCal ? '#fb7185' : '#fb923c';
|
||||
|
||||
$card = [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'color' => $existing['color'] ?? $defaultColor,
|
||||
'bucket_id' => $existing['bucket_id'] ?? null,
|
||||
'parent_id' => $existing['parent_id'] ?? null,
|
||||
'position' => $existing['position'] ?? -1,
|
||||
];
|
||||
|
||||
return $card;
|
||||
}
|
||||
|
||||
private function eventDate(array $vevent): ?string
|
||||
{
|
||||
$dt = $vevent['dtstart'] ?? '';
|
||||
if (preg_match('/^(\d{4})(\d{2})(\d{2})/', $dt, $dm)) {
|
||||
return "{$dm[1]}-{$dm[2]}-{$dm[3]}";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolveCalendarUrls(CalDavClient $client): array
|
||||
{
|
||||
$parsed = parse_url($client->getBaseUrl());
|
||||
$base = $parsed['scheme'] . '://' . $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : '');
|
||||
|
||||
$calendars = $client->listCalendars();
|
||||
if (empty($calendars)) {
|
||||
return ['all' => [$client->getBaseUrl()], 'push' => $client->getBaseUrl(), 'push_birthday' => $client->getBaseUrl()];
|
||||
}
|
||||
|
||||
$allUrls = [];
|
||||
$pushUrl = null;
|
||||
$birthdayUrl = null;
|
||||
$preferred = $this->getSetting('calendar');
|
||||
|
||||
foreach ($calendars as $cal) {
|
||||
$url = $base . $cal['href'];
|
||||
$allUrls[] = $url;
|
||||
$this->addLog('sync', null, null, 'init', 'ok', "found calendar: {$cal['name']}");
|
||||
if ($preferred && ($cal['name'] === $preferred || str_contains($cal['href'], $preferred))) {
|
||||
$pushUrl = $url;
|
||||
}
|
||||
if (stripos($cal['name'], 'geburtstag') !== false || stripos($cal['name'], 'birthday') !== false) {
|
||||
$birthdayUrl = $url;
|
||||
}
|
||||
}
|
||||
|
||||
// Default push: first non-birthday/non-contact calendar
|
||||
if (!$pushUrl) {
|
||||
foreach ($calendars as $cal) {
|
||||
if (stripos($cal['name'], 'geburtstag') === false && stripos($cal['name'], 'birthday') === false && stripos($cal['name'], 'contact') === false) {
|
||||
$pushUrl = $base . $cal['href'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$pushUrl) $pushUrl = $allUrls[0];
|
||||
}
|
||||
if (!$birthdayUrl) $birthdayUrl = $pushUrl;
|
||||
|
||||
$this->addLog('sync', null, null, 'init', 'ok', "push termine: {$pushUrl}");
|
||||
$this->addLog('sync', null, null, 'init', 'ok', "push geburtstage: {$birthdayUrl}");
|
||||
return ['all' => $allUrls, 'push' => $pushUrl, 'push_birthday' => $birthdayUrl];
|
||||
}
|
||||
|
||||
private function makeClient(string $url): CalDavClient
|
||||
{
|
||||
$user = $this->getSetting('username') ? $this->decrypt($this->getSetting('username')) : '';
|
||||
$pass = $this->getSetting('password') ? $this->decrypt($this->getSetting('password')) : '';
|
||||
return new CalDavClient($url, $user, $pass);
|
||||
}
|
||||
|
||||
private function isBirthday(array $card): bool
|
||||
{
|
||||
$tags = array_map('trim', explode(',', $card['tags'] ?? ''));
|
||||
return in_array('geburtstag', $tags);
|
||||
}
|
||||
|
||||
private function getPushClient(array $card, CalDavClient $pushClient, CalDavClient $birthdayClient, array $allUrls): CalDavClient
|
||||
{
|
||||
if ($this->isBirthday($card)) return $birthdayClient;
|
||||
// Match calendar tag to URL slug
|
||||
$tags = array_map('trim', explode(',', $card['tags'] ?? ''));
|
||||
foreach ($tags as $tag) {
|
||||
if ($tag && $tag !== 'termin' && $tag !== 'geburtstag') {
|
||||
foreach ($allUrls as $url) {
|
||||
$slug = basename(rtrim($url, '/'));
|
||||
if ($slug === $tag) {
|
||||
$this->addLog('sync', $card['id'] ?? null, null, 'route', 'ok', "tag #{$tag} → {$slug}");
|
||||
return $this->makeClient($url);
|
||||
}
|
||||
}
|
||||
$this->addLog('sync', $card['id'] ?? null, null, 'route', 'error', "tag #{$tag} no match in: " . implode(', ', array_map(fn($u) => basename(rtrim($u, '/')), $allUrls)));
|
||||
}
|
||||
}
|
||||
$this->addLog('sync', $card['id'] ?? null, null, 'route', 'ok', 'using default push calendar');
|
||||
return $pushClient;
|
||||
}
|
||||
|
||||
// ── Sync ──
|
||||
|
||||
private function addLog(string $dir, ?int $cardId, ?string $uid, string $action, string $status, string $detail = ''): void
|
||||
{
|
||||
$this->log[] = compact('dir', 'cardId', 'uid', 'action', 'status', 'detail');
|
||||
$this->db->prepare('INSERT INTO caldav_sync_log (direction, card_id, caldav_uid, action, status, detail) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
->execute([$dir, $cardId, $uid, $action, $status, $detail]);
|
||||
}
|
||||
|
||||
public function sync(): array
|
||||
{
|
||||
// Skip if no credentials configured
|
||||
$url = $this->getSetting('url');
|
||||
$pass = $this->getSetting('password');
|
||||
if (!$url || !$pass) {
|
||||
return ['ok' => true, 'log' => [], 'stats' => $this->stats];
|
||||
}
|
||||
|
||||
$baseClient = $this->getClient();
|
||||
|
||||
// 0. Resolve all calendars
|
||||
$urls = $this->resolveCalendarUrls($baseClient);
|
||||
if (empty($urls['all'])) {
|
||||
$this->addLog('sync', null, null, 'init', 'error', 'no calendar found — check URL');
|
||||
return ['ok' => false, 'error' => 'no calendar found', 'log' => $this->log, 'stats' => $this->stats];
|
||||
}
|
||||
$pushClient = $this->makeClient($urls['push']);
|
||||
$birthdayClient = $this->makeClient($urls['push_birthday']);
|
||||
|
||||
// 1-2. Fetch remote events from ALL calendars
|
||||
$remoteMap = []; // uid -> event data (gets modified during reconciliation)
|
||||
$remoteAll = []; // uid -> event data (unmodified copy for tombstone lookups)
|
||||
$remoteEvents = [];
|
||||
foreach ($urls['all'] as $calUrl) {
|
||||
$calSlug = basename(rtrim($calUrl, '/'));
|
||||
$cal = $this->makeClient($calUrl);
|
||||
$res = $cal->request('REPORT', $calUrl, '<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"/></c:comp-filter></c:filter></c:calendar-query>', ['Depth: 1']);
|
||||
$this->addLog('sync', null, null, 'fetch', $res['status'] == 207 ? 'ok' : 'error', "REPORT {$calSlug} HTTP {$res['status']}");
|
||||
$events = $cal->listEvents();
|
||||
$this->addLog('sync', null, null, 'fetch', 'ok', count($events) . " events from {$calSlug}");
|
||||
$parseFail = 0;
|
||||
foreach ($events as $ev) {
|
||||
$parsed = CalDavClient::parseVEvent($ev['ical']);
|
||||
if ($parsed && $parsed['uid']) {
|
||||
$data = array_merge($parsed, ['href' => $ev['href'], 'etag' => $ev['etag'], 'ical' => $ev['ical'], 'cal_url' => $calUrl]);
|
||||
$remoteMap[$parsed['uid']] = $data;
|
||||
$remoteAll[$parsed['uid']] = $data;
|
||||
$remoteEvents[] = $ev;
|
||||
} else {
|
||||
$parseFail++;
|
||||
}
|
||||
}
|
||||
if ($parseFail) $this->addLog('sync', null, null, 'fetch', 'error', "{$parseFail} events failed to parse from {$calSlug}");
|
||||
}
|
||||
|
||||
$this->addLog('sync', null, null, 'fetch', 'ok', "total remote: " . count($remoteMap) . " events");
|
||||
|
||||
// 3. Fetch local appointment cards — only #termin tagged or already synced
|
||||
$stmt = $this->db->query("SELECT * FROM cards WHERE tags = 'termin' OR tags LIKE 'termin,%' OR tags LIKE '%,termin,%' OR tags LIKE '%,termin' OR tags = 'geburtstag' OR tags LIKE 'geburtstag,%' OR tags LIKE '%,geburtstag,%' OR tags LIKE '%,geburtstag' OR caldav_uid IS NOT NULL");
|
||||
$localCards = $stmt->fetchAll();
|
||||
$localMap = []; // caldav_uid -> card
|
||||
$localNew = []; // cards without caldav_uid
|
||||
foreach ($localCards as $c) {
|
||||
if ($c['caldav_uid']) {
|
||||
$localMap[$c['caldav_uid']] = $c;
|
||||
} else {
|
||||
$localNew[] = $c;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Tombstones
|
||||
$tombstones = [];
|
||||
foreach ($this->db->query('SELECT * FROM caldav_tombstones')->fetchAll() as $t) {
|
||||
$tombstones[$t['caldav_uid']] = $t;
|
||||
}
|
||||
|
||||
// 5. Reconciliation
|
||||
|
||||
// A: Local cards with UID — update or detect remote deletion
|
||||
foreach ($localMap as $uid => $card) {
|
||||
if (isset($remoteMap[$uid])) {
|
||||
$remote = $remoteMap[$uid];
|
||||
$remoteClient = $this->makeClient($remote['cal_url']);
|
||||
$localChanged = !$card['caldav_synced_at'] || $card['updated_at'] > $card['caldav_synced_at'];
|
||||
$remoteChanged = $card['caldav_etag'] !== $remote['etag'];
|
||||
|
||||
// Always backfill rrule if missing locally but present remotely
|
||||
$remoteRRule = $remote['rrule'] ?? null;
|
||||
if ($remoteRRule && $card['caldav_rrule'] !== $remoteRRule) {
|
||||
$this->db->prepare('UPDATE cards SET caldav_rrule = ? WHERE id = ?')
|
||||
->execute([$remoteRRule, $card['id']]);
|
||||
$this->addLog('pull', $card['id'], $uid, 'rrule-fix', 'ok', $remoteRRule);
|
||||
}
|
||||
|
||||
if ($localChanged && $remoteChanged) {
|
||||
$localTime = strtotime($card['updated_at']);
|
||||
$remoteTime = $remote['last_modified'] ? strtotime($remote['last_modified']) : 0;
|
||||
if ($localTime >= $remoteTime) {
|
||||
$this->pushCard($remoteClient, $card, $remote['href'], $remote['etag']);
|
||||
} else {
|
||||
$this->pullEvent($remote, $card);
|
||||
}
|
||||
$this->stats['conflicts']++;
|
||||
} elseif ($localChanged) {
|
||||
$this->pushCard($remoteClient, $card, $remote['href'], $remote['etag']);
|
||||
} elseif ($remoteChanged) {
|
||||
$this->pullEvent($remote, $card);
|
||||
}
|
||||
unset($remoteMap[$uid]);
|
||||
} else {
|
||||
// Event not on server — was it deleted remotely or never pushed?
|
||||
$localChanged = !$card['caldav_synced_at'] || $card['updated_at'] > $card['caldav_synced_at'];
|
||||
$wasSynced = !empty($card['caldav_synced_at']);
|
||||
if ($localChanged) {
|
||||
// Local edit or never synced → re-push
|
||||
$ical = $this->cardToVEvent($card);
|
||||
$targetClient = $this->getPushClient($card, $pushClient, $birthdayClient, $urls['all']);
|
||||
$res = $targetClient->createEvent($card['caldav_uid'], $ical);
|
||||
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $card['caldav_uid']);
|
||||
if ($res['status'] >= 200 && $res['status'] < 300) {
|
||||
$this->updateCardSync($card['id'], $card['caldav_uid'], $res['etag'], $filename . '.ics');
|
||||
$this->addLog('push', $card['id'], $uid, 'recreate', 'ok', $wasSynced ? 'was deleted, local edit wins' : 'first push');
|
||||
$this->stats['pushed']++;
|
||||
} else {
|
||||
$this->addLog('push', $card['id'], $uid, 'recreate', 'error', "HTTP {$res['status']}");
|
||||
}
|
||||
} else {
|
||||
// No local changes + was synced before → remote intentionally deleted
|
||||
$title = $card['title'] ?? '';
|
||||
$this->db->prepare('DELETE FROM cards WHERE id = ?')->execute([$card['id']]);
|
||||
$this->addLog('pull', $card['id'], $uid, 'delete', 'ok', "remote deleted: {$title}");
|
||||
$this->stats['deleted']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// B: New local cards → push to appropriate calendar
|
||||
foreach ($localNew as $card) {
|
||||
$uid = 'card-' . $card['id'] . '@hyprcrd';
|
||||
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $uid);
|
||||
$ical = $this->cardToVEvent(array_merge($card, ['caldav_uid' => $uid]));
|
||||
$targetClient = $this->getPushClient($card, $pushClient, $birthdayClient, $urls['all']);
|
||||
$targetUrl = $targetClient->getBaseUrl();
|
||||
$targetSlug = basename(rtrim($targetUrl, '/'));
|
||||
$this->addLog('push', $card['id'], $uid, 'create', 'ok', "→ {$targetSlug} ({$targetUrl}) tags={$card['tags']}");
|
||||
$res = $targetClient->createEvent($uid, $ical);
|
||||
if ($res['status'] >= 200 && $res['status'] < 300) {
|
||||
$this->updateCardSync($card['id'], $uid, $res['etag'], $filename . '.ics');
|
||||
$this->addLog('push', $card['id'], $uid, 'create', 'ok', "done in {$targetSlug}");
|
||||
$this->stats['pushed']++;
|
||||
} else {
|
||||
$putUrl = rtrim($targetUrl, '/') . '/' . $filename . '.ics';
|
||||
$this->addLog('push', $card['id'], $uid, 'create', 'error', "HTTP {$res['status']} PUT {$putUrl}" . ($res['error'] ? " — {$res['error']}" : ''));
|
||||
}
|
||||
}
|
||||
|
||||
// C: New remote events from ALL calendars → pull (skip past non-recurring)
|
||||
$today = date('Y-m-d');
|
||||
$skippedPast = 0;
|
||||
foreach ($remoteMap as $uid => $remote) {
|
||||
if (isset($tombstones[$uid])) continue;
|
||||
|
||||
// Skip past events (unless recurring)
|
||||
$evDate = $this->eventDate($remote);
|
||||
$isRecurring = !empty($remote['rrule']);
|
||||
if ($evDate && $evDate < $today && !$isRecurring) {
|
||||
$skippedPast++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$calName = basename(rtrim($remote['cal_url'] ?? '', '/'));
|
||||
$cardData = $this->vEventToCard($remote, null, $calName);
|
||||
$tags = $this->extractTags($cardData['body']);
|
||||
$stmt = $this->db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags, caldav_uid, caldav_etag, caldav_href, caldav_rrule, caldav_synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime("now"))');
|
||||
$stmt->execute([$cardData['title'], $cardData['body'], $cardData['color'], $cardData['bucket_id'], $cardData['parent_id'], $cardData['position'], $tags, $uid, $remote['etag'], $remote['href'], $remote['rrule'] ?? null]);
|
||||
$this->addLog('pull', (int) $this->db->lastInsertId(), $uid, 'create', 'ok', "from {$calName}");
|
||||
$this->stats['pulled']++;
|
||||
}
|
||||
|
||||
if ($skippedPast) $this->addLog('sync', null, null, 'skip', 'ok', "{$skippedPast} past events skipped");
|
||||
|
||||
// D: Tombstones → delete from remote calendar
|
||||
foreach ($tombstones as $uid => $tomb) {
|
||||
if (isset($remoteAll[$uid])) {
|
||||
$remote = $remoteAll[$uid];
|
||||
$delClient = $this->makeClient($remote['cal_url']);
|
||||
$res = $delClient->deleteEvent($remote['href'], $remote['etag']);
|
||||
if ($res['status'] >= 200 && $res['status'] < 300) {
|
||||
$this->addLog('push', $tomb['card_id'], $uid, 'delete', 'ok', basename(rtrim($remote['cal_url'], '/')));
|
||||
$this->stats['deleted']++;
|
||||
} else {
|
||||
$this->addLog('push', $tomb['card_id'], $uid, 'delete', 'error', "HTTP {$res['status']}");
|
||||
}
|
||||
} else {
|
||||
$this->addLog('push', $tomb['card_id'], $uid, 'delete', 'ok', 'already gone from server');
|
||||
}
|
||||
$this->db->prepare('DELETE FROM caldav_tombstones WHERE caldav_uid = ?')->execute([$uid]);
|
||||
}
|
||||
|
||||
// 6. Update sync metadata
|
||||
$this->setSetting('last_sync', date('Y-m-d H:i:s'));
|
||||
|
||||
return ['ok' => true, 'log' => $this->log, 'stats' => $this->stats];
|
||||
}
|
||||
|
||||
private function pushCard(CalDavClient $client, array $card, string $href, string $etag): void
|
||||
{
|
||||
$ical = $this->cardToVEvent($card);
|
||||
$res = $client->updateEvent($href, $ical, $etag);
|
||||
if ($res['status'] >= 200 && $res['status'] < 300) {
|
||||
$this->updateCardSync($card['id'], $card['caldav_uid'], $res['etag'], $href);
|
||||
$this->addLog('push', $card['id'], $card['caldav_uid'], 'update', 'ok');
|
||||
$this->stats['pushed']++;
|
||||
} else {
|
||||
$this->addLog('push', $card['id'], $card['caldav_uid'], 'update', 'error', "HTTP {$res['status']}");
|
||||
}
|
||||
}
|
||||
|
||||
private function pullEvent(array $remote, array $card): void
|
||||
{
|
||||
$calName = basename(rtrim($remote['cal_url'] ?? '', '/'));
|
||||
$cardData = $this->vEventToCard($remote, $card, $calName);
|
||||
$tags = $this->extractTags($cardData['body']);
|
||||
$this->db->prepare('UPDATE cards SET title=?, body=?, tags=?, caldav_etag=?, caldav_href=?, caldav_rrule=?, caldav_synced_at=datetime("now"), updated_at=datetime("now") WHERE id=?')
|
||||
->execute([$cardData['title'], $cardData['body'], $tags, $remote['etag'], $remote['href'], $remote['rrule'] ?? null, $card['id']]);
|
||||
$this->addLog('pull', $card['id'], $remote['uid'], 'update', 'ok');
|
||||
$this->stats['pulled']++;
|
||||
}
|
||||
|
||||
private function updateCardSync(int $cardId, string $uid, ?string $etag, string $href): void
|
||||
{
|
||||
$this->db->prepare('UPDATE cards SET caldav_uid=?, caldav_etag=?, caldav_href=?, caldav_synced_at=datetime("now") WHERE id=?')
|
||||
->execute([$uid, $etag, $href, $cardId]);
|
||||
}
|
||||
|
||||
private function extractTags(string $body): string
|
||||
{
|
||||
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-][a-zA-Z0-9_\/-]*[a-zA-Z0-9_-]|[a-zA-Z0-9_-]+)/m', $body, $matches);
|
||||
return implode(',', array_unique(array_map('strtolower', $matches[1] ?? [])));
|
||||
}
|
||||
|
||||
private function findEventByUid(array $events, string $uid): ?array
|
||||
{
|
||||
foreach ($events as $ev) {
|
||||
if (str_contains($ev['ical'], 'UID:' . $uid)) return $ev;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,39 @@ class Database
|
||||
}
|
||||
}
|
||||
|
||||
// CalDAV columns
|
||||
if (!in_array('caldav_uid', $colNames)) {
|
||||
self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_uid TEXT DEFAULT NULL');
|
||||
self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_etag TEXT DEFAULT NULL');
|
||||
self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_href TEXT DEFAULT NULL');
|
||||
self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_synced_at TEXT DEFAULT NULL');
|
||||
self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_rrule TEXT DEFAULT NULL');
|
||||
}
|
||||
if (!in_array('caldav_rrule', $colNames) && in_array('caldav_uid', $colNames)) {
|
||||
self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_rrule TEXT DEFAULT NULL');
|
||||
}
|
||||
|
||||
// CalDAV support tables
|
||||
self::$pdo->exec('
|
||||
CREATE TABLE IF NOT EXISTS caldav_sync_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
direction TEXT NOT NULL,
|
||||
card_id INTEGER,
|
||||
caldav_uid TEXT,
|
||||
action TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
detail TEXT DEFAULT "",
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
');
|
||||
self::$pdo->exec('
|
||||
CREATE TABLE IF NOT EXISTS caldav_tombstones (
|
||||
caldav_uid TEXT PRIMARY KEY,
|
||||
card_id INTEGER,
|
||||
deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
');
|
||||
|
||||
// Bucket color migration
|
||||
$bcols = self::$pdo->query('PRAGMA table_info(buckets)')->fetchAll();
|
||||
$bcolNames = array_column($bcols, 'name');
|
||||
|
||||
Reference in new issue
Block a user