Compare commits
2
Commits
fe93a0f80e
...
416ca9e854
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
416ca9e854 | ||
|
|
940d9bbde4 |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '0.1.1');
|
define('APP_VERSION', '0.3.0');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Build hyprcrd distribution ZIP with version bump
|
# Build hyprcrd distribution ZIP with version bump + minification
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
|
||||||
|
echo "Usage: ./dist.sh [--patch|--minor|--major]"
|
||||||
|
echo ""
|
||||||
|
echo " --patch bump patch version (default) 0.1.1 → 0.1.2"
|
||||||
|
echo " --minor bump minor version 0.1.2 → 0.2.0"
|
||||||
|
echo " --major bump major version 0.2.0 → 1.0.0"
|
||||||
|
echo ""
|
||||||
|
echo "Builds Tailwind CSS, minifies JS/CSS, bumps version in config.php,"
|
||||||
|
echo "and creates hyprcrd-{version}.zip ready for deployment."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
# Read current version
|
# Read current version
|
||||||
@@ -20,6 +32,8 @@ sed -i "s/APP_VERSION', '${CURRENT}'/APP_VERSION', '${VERSION}'/" config.php
|
|||||||
echo "Version: ${CURRENT} → ${VERSION}"
|
echo "Version: ${CURRENT} → ${VERSION}"
|
||||||
|
|
||||||
DIST="hyprcrd-${VERSION}.zip"
|
DIST="hyprcrd-${VERSION}.zip"
|
||||||
|
STAGE="/tmp/hyprcrd-stage-$$"
|
||||||
|
rm -rf "$STAGE"
|
||||||
|
|
||||||
# Rebuild Tailwind if CLI exists
|
# Rebuild Tailwind if CLI exists
|
||||||
if [ -x ./tailwindcss ]; then
|
if [ -x ./tailwindcss ]; then
|
||||||
@@ -27,29 +41,48 @@ if [ -x ./tailwindcss ]; then
|
|||||||
./tailwindcss --input public/css/tailwind.css --output public/css/tailwind.out.css --content "public/index.php,public/js/**/*.js" --minify
|
./tailwindcss --input public/css/tailwind.css --output public/css/tailwind.out.css --content "public/index.php,public/js/**/*.js" --minify
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Copy source to staging
|
||||||
|
echo "Staging files..."
|
||||||
|
mkdir -p "$STAGE"
|
||||||
|
cp -r config.php Dockerfile docker-entrypoint.sh README.md dist.sh .gitignore src public "$STAGE/"
|
||||||
|
mkdir -p "$STAGE/data"
|
||||||
|
touch "$STAGE/data/.gitkeep"
|
||||||
|
rm -rf "$STAGE/public/uploads"
|
||||||
|
mkdir -p "$STAGE/public/uploads"
|
||||||
|
touch "$STAGE/public/uploads/.gitkeep"
|
||||||
|
rm -f "$STAGE/public/css/tailwind.css"
|
||||||
|
|
||||||
|
# Minify JS
|
||||||
|
if command -v npx &>/dev/null; then
|
||||||
|
echo "Minifying JS..."
|
||||||
|
for f in "$STAGE"/public/js/app.js "$STAGE"/public/js/modules/*.js; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
npx --yes terser "$f" --compress --mangle --module -o "$f" 2>/dev/null && \
|
||||||
|
echo " $(basename "$f")" || echo " SKIP $(basename "$f")"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Minifying CSS..."
|
||||||
|
for f in "$STAGE"/public/css/style.css "$STAGE"/public/css/fonts.css; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
npx --yes csso-cli "$f" -o "$f" 2>/dev/null && \
|
||||||
|
echo " $(basename "$f")" || echo " SKIP $(basename "$f")"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "npx not found, skipping minification"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create ZIP
|
||||||
echo "Creating ${DIST}..."
|
echo "Creating ${DIST}..."
|
||||||
|
cd "$STAGE"
|
||||||
zip -r "$DIST" \
|
zip -r "$OLDPWD/$DIST" . -x "*.gitkeep" 2>/dev/null
|
||||||
config.php \
|
# Re-add gitkeep files
|
||||||
Dockerfile \
|
zip "$OLDPWD/$DIST" data/.gitkeep public/uploads/.gitkeep
|
||||||
docker-entrypoint.sh \
|
|
||||||
README.md \
|
|
||||||
dist.sh \
|
|
||||||
.gitignore \
|
|
||||||
src/ \
|
|
||||||
public/ \
|
|
||||||
-x "public/uploads/*" \
|
|
||||||
-x "public/uploads/.gitkeep" \
|
|
||||||
-x "public/css/tailwind.css"
|
|
||||||
|
|
||||||
# Add empty dirs with gitkeep
|
|
||||||
mkdir -p /tmp/hyprcrd-dist/data
|
|
||||||
mkdir -p /tmp/hyprcrd-dist/public/uploads
|
|
||||||
touch /tmp/hyprcrd-dist/data/.gitkeep
|
|
||||||
touch /tmp/hyprcrd-dist/public/uploads/.gitkeep
|
|
||||||
cd /tmp/hyprcrd-dist
|
|
||||||
zip -r "$OLDPWD/$DIST" data/.gitkeep public/uploads/.gitkeep
|
|
||||||
cd "$OLDPWD"
|
cd "$OLDPWD"
|
||||||
rm -rf /tmp/hyprcrd-dist
|
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
rm -rf "$STAGE"
|
||||||
|
|
||||||
|
# Size comparison
|
||||||
|
ORIG=$(du -sb public/js/ public/css/style.css | awk '{s+=$1}END{print s}')
|
||||||
|
FINAL=$(stat -c%s "$DIST")
|
||||||
echo "Done: ${DIST} ($(du -h "$DIST" | cut -f1))"
|
echo "Done: ${DIST} ($(du -h "$DIST" | cut -f1))"
|
||||||
+47
-38
@@ -18,6 +18,19 @@ body { font-family: 'JetBrains Mono', monospace; }
|
|||||||
.cal-item-journal { opacity: 0.8; }
|
.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; }
|
.cal-todo { font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 8px; color: var(--t-accent); flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Datepicker */
|
||||||
|
.dp-header { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
|
||||||
|
.dp-header button { background: none; border: 1px solid var(--t-border); color: var(--t-muted); cursor: pointer; padding: 2px 8px; border-radius: 3px; transition: color 0.1s, border-color 0.1s; }
|
||||||
|
.dp-header button:hover { color: var(--t-accent); border-color: var(--t-accent); }
|
||||||
|
.dp-title { flex: 1; text-align: center; font-family: var(--t-font-pixel); font-size: 10px; color: var(--t-bright); }
|
||||||
|
.dp-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
|
||||||
|
.dp-day-label { text-align: center; font-family: var(--t-font-pixel); font-size: 8px; color: var(--t-dim); padding: 2px 0; text-transform: uppercase; }
|
||||||
|
.dp-day { text-align: center; font-size: 11px; color: var(--t-text); padding: 5px 0; cursor: pointer; border-radius: 3px; transition: background 0.1s, color 0.1s; }
|
||||||
|
.dp-day:hover { background: var(--t-raised); color: var(--t-bright); }
|
||||||
|
.dp-day.dp-out { color: var(--t-dim); opacity: 0.4; }
|
||||||
|
.dp-day.dp-today { color: var(--t-accent); font-weight: bold; }
|
||||||
|
.dp-day.dp-selected { background: var(--t-accent); color: var(--t-bg); }
|
||||||
|
|
||||||
/* Scrollbars */
|
/* Scrollbars */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@@ -560,46 +573,42 @@ body.detail-image-fs-open { overflow: hidden; }
|
|||||||
.prose.tree .bullet-list li::marker { color: var(--t-accent); font-size: inherit; }
|
.prose.tree .bullet-list li::marker { color: var(--t-accent); font-size: inherit; }
|
||||||
.prose blockquote { border-left: 3px solid var(--t-accent); padding-left: 1em; margin: 0.8em 0; color: var(--t-muted); }
|
.prose blockquote { border-left: 3px solid var(--t-accent); padding-left: 1em; margin: 0.8em 0; color: var(--t-muted); }
|
||||||
.prose hr { border: none; border-top: 1px solid var(--t-border); margin: 1.2em 0; }
|
.prose hr { border: none; border-top: 1px solid var(--t-border); margin: 1.2em 0; }
|
||||||
/* Todo checkboxes */
|
/* Todo component */
|
||||||
.prose input[type="checkbox"] {
|
.prose ul:has(.todo-item), .prose ol:has(.todo-item) { padding-left: 1.5em; }
|
||||||
appearance: none;
|
.todo-item {
|
||||||
width: 30px;
|
list-style: none !important;
|
||||||
height: 30px;
|
margin: 0 !important;
|
||||||
border: 1px solid var(--t-dim);
|
margin-left: -1.5em !important;
|
||||||
border-radius: 4px;
|
display: flex !important;
|
||||||
background: var(--t-bg);
|
align-items: flex-start;
|
||||||
cursor: pointer;
|
gap: 10px;
|
||||||
vertical-align: middle;
|
min-height: 28px;
|
||||||
margin-right: 8px;
|
padding: 5px 0;
|
||||||
position: relative;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
.prose input[type="checkbox"]:checked {
|
.todo-item { cursor: pointer; }
|
||||||
background: var(--t-accent);
|
.todo-item:hover { background: color-mix(in srgb, var(--t-raised) 50%, transparent); }
|
||||||
border-color: var(--t-accent);
|
.todo-item.todo-done .todo-text { color: var(--t-dim); text-decoration: line-through; }
|
||||||
|
.todo-text { flex: 1; min-width: 0; line-height: 1.5; user-select: none; }
|
||||||
|
.todo-cb {
|
||||||
|
width: 18px; height: 18px; min-width: 18px;
|
||||||
|
transform: translateY(-9px);
|
||||||
|
border: 2px solid var(--t-border); border-radius: 3px;
|
||||||
|
background: transparent; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 0; font-size: 0;
|
||||||
|
transition: border-color 0.12s, background 0.12s;
|
||||||
}
|
}
|
||||||
.prose input[type="checkbox"]::after {
|
.todo-cb:hover { border-color: var(--t-accent); }
|
||||||
content: '';
|
.todo-cb.checked { background: var(--t-accent); border-color: var(--t-accent); }
|
||||||
position: absolute;
|
.todo-cb i { font-size: 12px; transition: opacity 0.12s; }
|
||||||
left: 9px;
|
.todo-cb:not(.checked) i { opacity: 0; color: var(--t-accent); }
|
||||||
top: 4px;
|
.todo-cb:not(.checked):hover i { opacity: 0.5; }
|
||||||
width: 8px;
|
.todo-cb.checked i { opacity: 1; color: var(--t-bg); }
|
||||||
height: 14px;
|
.prose input[type="checkbox"] { display: none; }
|
||||||
border: solid transparent;
|
|
||||||
border-width: 0 3px 3px 0;
|
/* Todo progress bar */
|
||||||
transform: rotate(45deg);
|
.todo-progress { display: flex; flex: 1; gap: 3px; }
|
||||||
opacity: 0;
|
.todo-seg { flex: 1; height: 5px; border-radius: 3px; transition: background 0.2s; }
|
||||||
transition: opacity 0.12s ease, border-color 0.12s ease;
|
|
||||||
}
|
|
||||||
.prose input[type="checkbox"]:checked::after {
|
|
||||||
border-color: var(--t-bg);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.prose input[type="checkbox"]:not(:checked):hover::after {
|
|
||||||
border-color: var(--t-accent);
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
.prose li:has(input[type="checkbox"]) { list-style: none; margin-left: -1.5em; }
|
|
||||||
|
|
||||||
.prose audio {
|
.prose audio {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
+3
-2
@@ -882,6 +882,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<div class="p-4 space-y-3">
|
<div class="p-4 space-y-3">
|
||||||
<p id="dialog-msg" class="text-xs text-t-text"></p>
|
<p id="dialog-msg" class="text-xs text-t-text"></p>
|
||||||
<input type="text" id="dialog-input" class="hidden w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-sm focus:border-t-accent focus:outline-none placeholder:text-t-dim font-mono">
|
<input type="text" id="dialog-input" class="hidden w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-sm focus:border-t-accent focus:outline-none placeholder:text-t-dim font-mono">
|
||||||
|
<div id="dialog-datepicker" class="hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2 px-4 py-3 border-t border-t-border">
|
<div class="flex justify-end gap-2 px-4 py-3 border-t border-t-border">
|
||||||
<button id="dialog-cancel" class="font-pixel text-[10px] px-3 py-1.5 text-xs border border-t-border text-t-muted hover:text-t-bright transition-colors">cancel</button>
|
<button id="dialog-cancel" class="font-pixel text-[10px] px-3 py-1.5 text-xs border border-t-border text-t-muted hover:text-t-bright transition-colors">cancel</button>
|
||||||
@@ -1146,11 +1147,11 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 min-h-0 overflow-hidden">
|
<div class="flex flex-1 min-h-0 overflow-hidden">
|
||||||
<div id="editor-gutter" class="hidden bg-t-bg text-t-dim text-right font-mono text-sm select-none overflow-hidden shrink-0 border-r border-t-border" style="width:3.5em; padding:1rem 0.25rem 1rem 0.5rem; line-height:1.625;"></div>
|
<div id="editor-gutter" class="hidden bg-t-bg text-t-dim text-right font-mono text-sm select-none overflow-hidden shrink-0 border-r border-t-border" style="width:3.5em; padding:1rem 0.25rem 1rem 0.5rem; line-height:1.625;"></div>
|
||||||
<div id="editor-pane" class="relative min-h-full bg-t-bg" style="flex:1 1 0%;min-width:0;">
|
<div id="editor-pane" class="relative min-h-full bg-t-bg overflow-hidden" style="flex:1 1 0%;min-width:0;">
|
||||||
<div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div>
|
<div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div>
|
||||||
<textarea id="editor-body" placeholder="> write markdown..." class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea>
|
<textarea id="editor-body" placeholder="> write markdown..." class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div id="editor-divider" class="hidden shrink-0 border-l border-t-border" style="width:1px;cursor:col-resize;position:relative;padding:0 5px;margin:0 -5px;z-index:3;"><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:48px;border-radius:2px;background:var(--t-muted);opacity:0.08;transition:opacity 0.2s;"></div></div>
|
<div id="editor-divider" class="hidden shrink-0" style="width:11px;cursor:col-resize;position:relative;z-index:3;margin:0 -5px;"><div style="position:absolute;left:5px;top:0;bottom:0;width:1px;background:var(--t-border);"></div><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:48px;border-radius:2px;background:var(--t-muted);opacity:0.08;transition:opacity 0.2s;"></div></div>
|
||||||
<div id="editor-preview" class="bg-t-bg p-4 overflow-y-auto hidden" style="flex:1 1 0%;min-width:0;"><div id="preview-content" class="prose text-sm text-t-text"></div></div>
|
<div id="editor-preview" class="bg-t-bg p-4 overflow-y-auto hidden" style="flex:1 1 0%;min-width:0;"><div id="preview-content" class="prose text-sm text-t-text"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,10 +5,17 @@ import { state, sortedCards, parseTags, tagChipHtml, getChildren, getRecent, get
|
|||||||
import { stripMd, getTodos, firstImage, leadingImage } from './markdown.js';
|
import { stripMd, getTodos, firstImage, leadingImage } from './markdown.js';
|
||||||
import { pushReorder, pushCardEdit, pushBatch, snapshotCard } from './undo.js';
|
import { pushReorder, pushCardEdit, pushBatch, snapshotCard } from './undo.js';
|
||||||
import { getCalConfig } from './caldav.js';
|
import { getCalConfig } from './caldav.js';
|
||||||
import { renderMd, addCodeCopyButtons } from './markdown.js';
|
import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
||||||
|
import { bindCheckboxes } from './detail.js';
|
||||||
|
|
||||||
const board = $('board');
|
const board = $('board');
|
||||||
const boardPreview = $('board-preview');
|
const boardPreview = $('board-preview');
|
||||||
|
|
||||||
|
board?.addEventListener('contextmenu', e => {
|
||||||
|
if (e.target.closest('[data-id]') || e.target.closest('.superview-item')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
window._openModal(null);
|
||||||
|
});
|
||||||
const boardPreviewBody = $('board-preview-body');
|
const boardPreviewBody = $('board-preview-body');
|
||||||
const boardPreviewTitle = $('board-preview-title');
|
const boardPreviewTitle = $('board-preview-title');
|
||||||
const boardPreviewColor = $('board-preview-color');
|
const boardPreviewColor = $('board-preview-color');
|
||||||
@@ -113,6 +120,8 @@ function showBoardPreview(cardId) {
|
|||||||
if (boardPreviewBody) {
|
if (boardPreviewBody) {
|
||||||
boardPreviewBody.innerHTML = renderMd(card.body);
|
boardPreviewBody.innerHTML = renderMd(card.body);
|
||||||
addCodeCopyButtons(boardPreviewBody);
|
addCodeCopyButtons(boardPreviewBody);
|
||||||
|
formatTodoItems(boardPreviewBody);
|
||||||
|
bindCheckboxes(boardPreviewBody, card);
|
||||||
}
|
}
|
||||||
const tags = parseTags(card);
|
const tags = parseTags(card);
|
||||||
if (boardPreviewTags) {
|
if (boardPreviewTags) {
|
||||||
@@ -214,7 +223,7 @@ $('sort-toggle')?.addEventListener('click', () => {
|
|||||||
renderBoard();
|
renderBoard();
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderCardHtml(c, inGroup = false, recent = []) {
|
export function renderCardHtml(c, inGroup = false, recent = []) {
|
||||||
const coverImg = leadingImage(c.body);
|
const coverImg = leadingImage(c.body);
|
||||||
const thumbImg = coverImg ? null : firstImage(c.body);
|
const thumbImg = coverImg ? null : firstImage(c.body);
|
||||||
const children = getChildren(c.id);
|
const children = getChildren(c.id);
|
||||||
@@ -243,10 +252,9 @@ function renderCardHtml(c, inGroup = false, recent = []) {
|
|||||||
${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>` : ''}
|
${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>
|
<h3 class="font-pixel text-xs text-t-bright leading-relaxed line-clamp-2">${esc(c.title)}</h3>
|
||||||
</div>
|
</div>
|
||||||
${todos ? `<div class="mb-2"><div class="flex items-center gap-2 mb-1">
|
${todos ? `<div class="mb-2">
|
||||||
<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>
|
<div class="todo-progress">${Array.from({length: todos.total}, (_, i) => `<div class="todo-seg" style="background:${i < todos.done ? cardColor : 'var(--t-border)'}"></div>`).join('')}</div>
|
||||||
<span class="font-pixel text-[9px] ${todos.done === todos.total ? 'text-t-accent' : 'text-t-dim'}">${todos.done}/${todos.total}</span>
|
</div>` : ''}
|
||||||
</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>
|
<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>` : ''}
|
${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">
|
<div class="mt-auto pt-2 flex items-center gap-2 text-[10px] ${hasCover ? 'text-t-text' : 'text-t-dim'} font-pixel">
|
||||||
@@ -283,7 +291,7 @@ export function renderBoard() {
|
|||||||
cards = cards.filter(c => favs.includes(String(c.id)));
|
cards = cards.filter(c => favs.includes(String(c.id)));
|
||||||
} else if (state.activeBucket === 'journal') {
|
} else if (state.activeBucket === 'journal') {
|
||||||
const today = new Date().toISOString().substring(0, 10);
|
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 = cards.filter(c => /^\d{4}-\d{2}-\d{2}/.test(c.title) && c.title.substring(0, 10) <= today && !(c.tags || '').split(',').includes('geburtstag'));
|
||||||
cards.sort((a, b) => b.title.localeCompare(a.title));
|
cards.sort((a, b) => b.title.localeCompare(a.title));
|
||||||
} else if (state.activeBucket === 'none') {
|
} else if (state.activeBucket === 'none') {
|
||||||
cards = cards.filter(c => !c.bucket_id && !/^\d{4}-\d{2}-\d{2}/.test(c.title));
|
cards = cards.filter(c => !c.bucket_id && !/^\d{4}-\d{2}-\d{2}/.test(c.title));
|
||||||
@@ -443,6 +451,9 @@ export function renderBoard() {
|
|||||||
// Footer count
|
// Footer count
|
||||||
const fc = $('footer-count');
|
const fc = $('footer-count');
|
||||||
if (fc) fc.textContent = `${cards.length} card${cards.length !== 1 ? 's' : ''} · ${state.cards.length} total`;
|
if (fc) fc.textContent = `${cards.length} card${cards.length !== 1 ? 's' : ''} · ${state.cards.length} total`;
|
||||||
|
|
||||||
|
// Refresh split preview if open
|
||||||
|
if (boardSplitOpen && boardPreviewCardId) showBoardPreview(boardPreviewCardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Group bar ──
|
// ── Group bar ──
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { $, esc } from './util.js';
|
|||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { stripMd, getTodos } from './markdown.js';
|
import { stripMd, getTodos } from './markdown.js';
|
||||||
import { autoSync, getCalConfig, getAllCalConfigs } from './caldav.js';
|
import { autoSync, getCalConfig, getAllCalConfigs } from './caldav.js';
|
||||||
|
import { renderCardHtml } from './board.js';
|
||||||
|
|
||||||
const overlay = $('calendar');
|
const overlay = $('calendar');
|
||||||
const grid = $('cal-grid');
|
const grid = $('cal-grid');
|
||||||
@@ -138,6 +139,12 @@ function isAppointment(card) {
|
|||||||
return tags.includes('termin') || card.caldav_uid || /^\d{4}-\d{2}-\d{2}\s*[—-]\s*.+/.test(card.title);
|
return tags.includes('termin') || card.caldav_uid || /^\d{4}-\d{2}-\d{2}\s*[—-]\s*.+/.test(card.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let calHover = null, calHoverTimer = null;
|
||||||
|
function clearCalHover() {
|
||||||
|
if (calHoverTimer) { clearTimeout(calHoverTimer); calHoverTimer = null; }
|
||||||
|
if (calHover) { calHover.remove(); calHover = null; }
|
||||||
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
title.textContent = `${MONTHS[viewMonth]} ${viewYear}`;
|
title.textContent = `${MONTHS[viewMonth]} ${viewYear}`;
|
||||||
|
|
||||||
@@ -234,11 +241,44 @@ function render() {
|
|||||||
|
|
||||||
grid.innerHTML = html;
|
grid.innerHTML = html;
|
||||||
|
|
||||||
|
// Hover card preview — clear any stale popups from previous render
|
||||||
|
clearCalHover();
|
||||||
|
|
||||||
// Click on card item → open card (hide calendar, restore on return)
|
// Click on card item → open card (hide calendar, restore on return)
|
||||||
grid.querySelectorAll('[data-card-id]').forEach(el => {
|
grid.querySelectorAll('[data-card-id]').forEach(el => {
|
||||||
let clickTimer = null;
|
let clickTimer = null;
|
||||||
|
|
||||||
|
el.addEventListener('mouseenter', () => {
|
||||||
|
clearCalHover();
|
||||||
|
calHoverTimer = setTimeout(() => {
|
||||||
|
const card = state.cards.find(c => c.id == el.dataset.cardId);
|
||||||
|
if (!card) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const textEl = el.querySelector('.truncate') || el;
|
||||||
|
const textRect = textEl.getBoundingClientRect();
|
||||||
|
calHover = document.createElement('div');
|
||||||
|
Object.assign(calHover.style, {
|
||||||
|
position: 'fixed', zIndex: '210', width: '280px',
|
||||||
|
filter: 'drop-shadow(0 8px 24px rgba(0,0,0,0.4))',
|
||||||
|
});
|
||||||
|
const left = textRect.right + 4;
|
||||||
|
const useLeft = left + 290 < window.innerWidth;
|
||||||
|
calHover.style.left = (useLeft ? left : textRect.left - 284) + 'px';
|
||||||
|
calHover.style.top = Math.min(rect.top - 10, window.innerHeight - 320) + 'px';
|
||||||
|
calHover.innerHTML = renderCardHtml(card);
|
||||||
|
calHover.addEventListener('mouseenter', () => { if (calHoverTimer) { clearTimeout(calHoverTimer); calHoverTimer = null; } });
|
||||||
|
calHover.addEventListener('mouseleave', () => clearCalHover());
|
||||||
|
calHover.addEventListener('mouseover', e => e.stopPropagation());
|
||||||
|
document.body.appendChild(calHover);
|
||||||
|
}, 400);
|
||||||
|
});
|
||||||
|
el.addEventListener('mouseleave', () => {
|
||||||
|
if (calHoverTimer) { clearTimeout(calHoverTimer); calHoverTimer = null; }
|
||||||
|
setTimeout(() => { if (calHover && !calHover.matches(':hover')) clearCalHover(); }, 150);
|
||||||
|
});
|
||||||
el.addEventListener('click', e => {
|
el.addEventListener('click', e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
clearCalHover();
|
||||||
if (clickTimer) return;
|
if (clickTimer) return;
|
||||||
clickTimer = setTimeout(() => {
|
clickTimer = setTimeout(() => {
|
||||||
clickTimer = null;
|
clickTimer = null;
|
||||||
@@ -250,6 +290,7 @@ function render() {
|
|||||||
});
|
});
|
||||||
el.addEventListener('dblclick', e => {
|
el.addEventListener('dblclick', e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
clearCalHover();
|
||||||
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
|
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
|
||||||
closeDayPopup();
|
closeDayPopup();
|
||||||
state._returnToCalendar = true;
|
state._returnToCalendar = true;
|
||||||
@@ -425,15 +466,26 @@ function openDayPopup(ds, cellEl) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close on click outside
|
// Close on click outside or ESC
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const closeHandler = e => {
|
const clickHandler = e => {
|
||||||
if (dayPopup && !dayPopup.contains(e.target)) {
|
if (dayPopup && !dayPopup.contains(e.target)) {
|
||||||
closeDayPopup();
|
closeDayPopup();
|
||||||
document.removeEventListener('click', closeHandler);
|
cleanup();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('click', closeHandler);
|
const keyHandler = e => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeDayPopup();
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
document.removeEventListener('click', clickHandler);
|
||||||
|
document.removeEventListener('keydown', keyHandler);
|
||||||
|
};
|
||||||
|
document.addEventListener('click', clickHandler);
|
||||||
|
document.addEventListener('keydown', keyHandler);
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ const tags = $('ctx-tags');
|
|||||||
|
|
||||||
export function hideContextMenu() { menu.classList.add('hidden'); }
|
export function hideContextMenu() { menu.classList.add('hidden'); }
|
||||||
|
|
||||||
|
let ctxCloseTimer = null;
|
||||||
|
menu.addEventListener('mouseleave', () => {
|
||||||
|
ctxCloseTimer = setTimeout(hideContextMenu, 400);
|
||||||
|
});
|
||||||
|
menu.addEventListener('mouseenter', () => {
|
||||||
|
if (ctxCloseTimer) { clearTimeout(ctxCloseTimer); ctxCloseTimer = null; }
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener('click', hideContextMenu);
|
document.addEventListener('click', hideContextMenu);
|
||||||
document.addEventListener('contextmenu', e => { if (!menu.contains(e.target)) hideContextMenu(); });
|
document.addEventListener('contextmenu', e => { if (!menu.contains(e.target)) hideContextMenu(); });
|
||||||
|
|
||||||
@@ -26,6 +34,7 @@ export function showContextMenu(x, y, card) {
|
|||||||
<button class="ctx-item" data-action="open"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[10px]">open</span></button>
|
<button class="ctx-item" data-action="open"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[10px]">open</span></button>
|
||||||
<button class="ctx-item" data-action="edit"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">edit</span></button>
|
<button class="ctx-item" data-action="edit"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">edit</span></button>
|
||||||
<button class="ctx-item" data-action="duplicate"><i class="ph ph-copy text-sm"></i><span class="font-pixel text-[10px]">duplicate</span></button>
|
<button class="ctx-item" data-action="duplicate"><i class="ph ph-copy text-sm"></i><span class="font-pixel text-[10px]">duplicate</span></button>
|
||||||
|
${/^\d{4}-\d{2}-\d{2}/.test(card.title) ? `<button class="ctx-item" data-action="move-date"><i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">move to date</span></button>` : ''}
|
||||||
<button class="ctx-item" data-action="save-md"><i class="ph ph-file-md text-sm"></i><span class="font-pixel text-[10px]">save .md</span></button>
|
<button class="ctx-item" data-action="save-md"><i class="ph ph-file-md text-sm"></i><span class="font-pixel text-[10px]">save .md</span></button>
|
||||||
<button class="ctx-item" data-action="favorite"><i class="ph${isFavorite(card.id) ? '-fill' : ''} ph-star text-sm ${isFavorite(card.id) ? 'text-amber-400' : ''}"></i><span class="font-pixel text-[10px]">${isFavorite(card.id) ? 'unfavorite' : 'favorite'}</span></button>
|
<button class="ctx-item" data-action="favorite"><i class="ph${isFavorite(card.id) ? '-fill' : ''} ph-star text-sm ${isFavorite(card.id) ? 'text-amber-400' : ''}"></i><span class="font-pixel text-[10px]">${isFavorite(card.id) ? 'unfavorite' : 'favorite'}</span></button>
|
||||||
<div class="ctx-sep"></div>
|
<div class="ctx-sep"></div>
|
||||||
@@ -155,7 +164,30 @@ export function showContextMenu(x, y, card) {
|
|||||||
const a = btn.dataset.action;
|
const a = btn.dataset.action;
|
||||||
if (a === 'open') { hideContextMenu(); window._openDetail(card.id); }
|
if (a === 'open') { hideContextMenu(); window._openDetail(card.id); }
|
||||||
else if (a === 'edit') { hideContextMenu(); window._openModal(card); }
|
else if (a === 'edit') { hideContextMenu(); window._openModal(card); }
|
||||||
else if (a === 'duplicate') { hideContextMenu(); await api('api/cards', { method: 'POST', body: { title: card.title + ' (copy)', body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } }); await window._reloadAll(); }
|
else if (a === 'duplicate') {
|
||||||
|
hideContextMenu();
|
||||||
|
const hasDate = /^\d{4}-\d{2}-\d{2}/.test(card.title);
|
||||||
|
if (hasDate) {
|
||||||
|
const curDate = card.title.substring(0, 10);
|
||||||
|
const newDate = await showDialog({ title: 'duplicate_to_date', input: 'date', defaultValue: curDate, okLabel: 'duplicate' });
|
||||||
|
if (!newDate) return;
|
||||||
|
const newTitle = card.title.replace(/^\d{4}-\d{2}-\d{2}/, newDate.trim());
|
||||||
|
await api('api/cards', { method: 'POST', body: { title: newTitle, body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } });
|
||||||
|
} else {
|
||||||
|
await api('api/cards', { method: 'POST', body: { title: card.title + ' (copy)', body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } });
|
||||||
|
}
|
||||||
|
await window._reloadAll();
|
||||||
|
}
|
||||||
|
else if (a === 'move-date') {
|
||||||
|
hideContextMenu();
|
||||||
|
const curDate = card.title.substring(0, 10);
|
||||||
|
const newDate = await showDialog({ title: 'move_to_date', input: 'date', defaultValue: curDate, okLabel: 'move' });
|
||||||
|
if (!newDate || newDate === curDate) return;
|
||||||
|
const newTitle = card.title.replace(/^\d{4}-\d{2}-\d{2}/, newDate.trim());
|
||||||
|
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { title: newTitle } });
|
||||||
|
await window._reloadAll();
|
||||||
|
if (card.caldav_uid) window._triggerSync?.();
|
||||||
|
}
|
||||||
else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
|
else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
|
||||||
else if (a === 'favorite') { hideContextMenu(); toggleFavorite(card.id); window._renderBoard(); }
|
else if (a === 'favorite') { hideContextMenu(); toggleFavorite(card.id); window._renderBoard(); }
|
||||||
else if (a === 'color') toggle(colors);
|
else if (a === 'color') toggle(colors);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { $, esc, api, fuzzyMatch } from './util.js';
|
import { $, esc, api, fuzzyMatch } from './util.js';
|
||||||
import { state, parseTags, tagChipHtml, getChildren, getGroupCards, getRecent, markRecent } from './state.js';
|
import { state, parseTags, tagChipHtml, getChildren, getGroupCards, getRecent, markRecent } from './state.js';
|
||||||
import { renderMd, stripMd, firstImage, addCodeCopyButtons } from './markdown.js';
|
import { renderMd, stripMd, firstImage, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
||||||
import { pushCardDelete, pushCardEdit, snapshotCard } from './undo.js';
|
import { pushCardDelete, pushCardEdit, snapshotCard } from './undo.js';
|
||||||
import { showDialog } from './dialog.js';
|
import { showDialog } from './dialog.js';
|
||||||
|
|
||||||
@@ -449,21 +449,30 @@ function showContent(card) {
|
|||||||
titleEl.textContent = card.title;
|
titleEl.textContent = card.title;
|
||||||
bodyEl.innerHTML = renderMd(card.body);
|
bodyEl.innerHTML = renderMd(card.body);
|
||||||
addCodeCopyButtons(bodyEl);
|
addCodeCopyButtons(bodyEl);
|
||||||
|
formatTodoItems(bodyEl);
|
||||||
applyReadingResizeHandles(bodyEl);
|
applyReadingResizeHandles(bodyEl);
|
||||||
bindDetailImageFullscreen(bodyEl);
|
bindDetailImageFullscreen(bodyEl);
|
||||||
|
|
||||||
// Interactive checkboxes
|
// Interactive checkboxes
|
||||||
bodyEl.querySelectorAll('input[type="checkbox"]').forEach((cb, idx) => {
|
bindCheckboxes(bodyEl, card);
|
||||||
cb.disabled = false;
|
}
|
||||||
cb.addEventListener('change', async () => {
|
|
||||||
|
export function bindCheckboxes(container, card) {
|
||||||
|
container.querySelectorAll('.todo-item').forEach((item, idx) => {
|
||||||
|
const btn = item.querySelector('.todo-cb');
|
||||||
|
if (!btn) return;
|
||||||
|
const toggle = async () => {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
const body = card.body.replace(/- \[([ x])\]/g, (m, s) => n++ === idx ? `- [${s === 'x' ? ' ' : 'x'}]` : m);
|
const body = card.body.replace(/- \[([ x])\]/g, (m, s) => n++ === idx ? `- [${s === 'x' ? ' ' : 'x'}]` : m);
|
||||||
card.body = body;
|
card.body = body;
|
||||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } });
|
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } });
|
||||||
const c = state.cards.find(x => x.id == card.id);
|
const c = state.cards.find(x => x.id == card.id);
|
||||||
if (c) c.body = body;
|
if (c) c.body = body;
|
||||||
|
btn.classList.toggle('checked');
|
||||||
|
item.classList.toggle('todo-done');
|
||||||
window._renderBoard();
|
window._renderBoard();
|
||||||
});
|
};
|
||||||
|
item.addEventListener('click', toggle);
|
||||||
});
|
});
|
||||||
|
|
||||||
metaEl.textContent = '> created: ' + card.created_at + ' | modified: ' + card.updated_at +
|
metaEl.textContent = '> created: ' + card.created_at + ' | modified: ' + card.updated_at +
|
||||||
@@ -886,7 +895,9 @@ function loadSplitCard(id) {
|
|||||||
splitContent.querySelectorAll('.card-link[data-card-id]').forEach(link => {
|
splitContent.querySelectorAll('.card-link[data-card-id]').forEach(link => {
|
||||||
link.addEventListener('click', () => loadSplitCard(link.dataset.cardId));
|
link.addEventListener('click', () => loadSplitCard(link.dataset.cardId));
|
||||||
});
|
});
|
||||||
|
bindCheckboxes(splitContent, card);
|
||||||
addCodeCopyButtons(splitContent);
|
addCodeCopyButtons(splitContent);
|
||||||
|
formatTodoItems(splitContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
function promoteSplitCard(id) {
|
function promoteSplitCard(id) {
|
||||||
|
|||||||
+106
-3
@@ -6,32 +6,135 @@ const overlay = $('dialog');
|
|||||||
const titleEl = $('dialog-title');
|
const titleEl = $('dialog-title');
|
||||||
const msgEl = $('dialog-msg');
|
const msgEl = $('dialog-msg');
|
||||||
const inputEl = $('dialog-input');
|
const inputEl = $('dialog-input');
|
||||||
|
const datepickerEl = $('dialog-datepicker');
|
||||||
const cancelBtn = $('dialog-cancel');
|
const cancelBtn = $('dialog-cancel');
|
||||||
const okBtn = $('dialog-ok');
|
const okBtn = $('dialog-ok');
|
||||||
const closeBtn = $('dialog-close');
|
const closeBtn = $('dialog-close');
|
||||||
let resolve = null;
|
let resolve = null;
|
||||||
|
let dpMode = false;
|
||||||
|
let dpYear, dpMonth, dpSelected;
|
||||||
|
|
||||||
|
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
||||||
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
function canCancel() { return !cancelBtn.classList.contains('hidden'); }
|
function canCancel() { return !cancelBtn.classList.contains('hidden'); }
|
||||||
|
|
||||||
function close(result) {
|
function close(result) {
|
||||||
overlay.classList.remove('open');
|
overlay.classList.remove('open');
|
||||||
|
datepickerEl.classList.add('hidden');
|
||||||
|
datepickerEl.innerHTML = '';
|
||||||
|
dpMode = false;
|
||||||
if (resolve) { resolve(result); resolve = null; }
|
if (resolve) { resolve(result); resolve = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
|
if (dpMode) {
|
||||||
|
close(dpSelected || null);
|
||||||
|
} else {
|
||||||
close(inputEl.classList.contains('hidden') ? true : inputEl.value);
|
close(inputEl.classList.contains('hidden') ? true : inputEl.value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showDialog({ title, message = '', input = false, placeholder = '', danger = false, okLabel = 'ok', cancelLabel = 'cancel' }) {
|
function pad(n) { return String(n).padStart(2, '0'); }
|
||||||
|
|
||||||
|
function renderDatepicker() {
|
||||||
|
const today = new Date();
|
||||||
|
const todayStr = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
|
||||||
|
const firstDay = new Date(dpYear, dpMonth, 1).getDay();
|
||||||
|
const startOffset = (firstDay + 6) % 7;
|
||||||
|
const daysInMonth = new Date(dpYear, dpMonth + 1, 0).getDate();
|
||||||
|
const prevDays = new Date(dpYear, dpMonth, 0).getDate();
|
||||||
|
|
||||||
|
let html = `<div class="dp-header">
|
||||||
|
<button data-dp-nav="-1"><i class="ph ph-caret-left text-xs"></i></button>
|
||||||
|
<span class="dp-title">${MONTHS[dpMonth]} ${dpYear}</span>
|
||||||
|
<button data-dp-nav="1"><i class="ph ph-caret-right text-xs"></i></button>
|
||||||
|
</div><div class="dp-grid">`;
|
||||||
|
|
||||||
|
for (const d of DAYS) html += `<div class="dp-day-label">${d}</div>`;
|
||||||
|
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
const dayNum = i - startOffset + 1;
|
||||||
|
let ds, display, outClass = '';
|
||||||
|
if (dayNum < 1) {
|
||||||
|
display = prevDays + dayNum;
|
||||||
|
const pm = dpMonth === 0 ? 11 : dpMonth - 1;
|
||||||
|
const py = dpMonth === 0 ? dpYear - 1 : dpYear;
|
||||||
|
ds = `${py}-${pad(pm + 1)}-${pad(display)}`;
|
||||||
|
outClass = 'dp-out';
|
||||||
|
} else if (dayNum > daysInMonth) {
|
||||||
|
display = dayNum - daysInMonth;
|
||||||
|
const nm = dpMonth === 11 ? 0 : dpMonth + 1;
|
||||||
|
const ny = dpMonth === 11 ? dpYear + 1 : dpYear;
|
||||||
|
ds = `${ny}-${pad(nm + 1)}-${pad(display)}`;
|
||||||
|
outClass = 'dp-out';
|
||||||
|
} else {
|
||||||
|
display = dayNum;
|
||||||
|
ds = `${dpYear}-${pad(dpMonth + 1)}-${pad(dayNum)}`;
|
||||||
|
}
|
||||||
|
const isToday = ds === todayStr ? 'dp-today' : '';
|
||||||
|
const isSel = ds === dpSelected ? 'dp-selected' : '';
|
||||||
|
html += `<div class="dp-day ${outClass} ${isToday} ${isSel}" data-dp-date="${ds}">${display}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
datepickerEl.innerHTML = html;
|
||||||
|
|
||||||
|
// Nav buttons
|
||||||
|
datepickerEl.querySelectorAll('[data-dp-nav]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
dpMonth += parseInt(btn.dataset.dpNav);
|
||||||
|
if (dpMonth > 11) { dpMonth = 0; dpYear++; }
|
||||||
|
if (dpMonth < 0) { dpMonth = 11; dpYear--; }
|
||||||
|
renderDatepicker();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Day click
|
||||||
|
datepickerEl.querySelectorAll('[data-dp-date]').forEach(day => {
|
||||||
|
day.addEventListener('click', () => {
|
||||||
|
dpSelected = day.dataset.dpDate;
|
||||||
|
// Parse selected date to navigate to its month
|
||||||
|
const [y, m] = dpSelected.split('-').map(Number);
|
||||||
|
if (y !== dpYear || m - 1 !== dpMonth) {
|
||||||
|
dpYear = y;
|
||||||
|
dpMonth = m - 1;
|
||||||
|
}
|
||||||
|
renderDatepicker();
|
||||||
|
});
|
||||||
|
day.addEventListener('dblclick', () => {
|
||||||
|
dpSelected = day.dataset.dpDate;
|
||||||
|
submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showDialog({ title, message = '', input = false, placeholder = '', defaultValue = '', danger = false, okLabel = 'ok', cancelLabel = 'cancel' }) {
|
||||||
return new Promise(res => {
|
return new Promise(res => {
|
||||||
resolve = res;
|
resolve = res;
|
||||||
titleEl.textContent = title;
|
titleEl.textContent = title;
|
||||||
msgEl.textContent = message;
|
msgEl.textContent = message;
|
||||||
msgEl.classList.toggle('hidden', !message);
|
msgEl.classList.toggle('hidden', !message);
|
||||||
|
dpMode = input === 'date';
|
||||||
|
|
||||||
|
if (dpMode) {
|
||||||
|
inputEl.classList.add('hidden');
|
||||||
|
datepickerEl.classList.remove('hidden');
|
||||||
|
const d = defaultValue ? new Date(defaultValue + 'T00:00:00') : new Date();
|
||||||
|
dpYear = d.getFullYear();
|
||||||
|
dpMonth = d.getMonth();
|
||||||
|
dpSelected = defaultValue || `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
renderDatepicker();
|
||||||
|
} else {
|
||||||
|
datepickerEl.classList.add('hidden');
|
||||||
|
datepickerEl.innerHTML = '';
|
||||||
inputEl.classList.toggle('hidden', input === false);
|
inputEl.classList.toggle('hidden', input === false);
|
||||||
inputEl.value = '';
|
inputEl.value = defaultValue;
|
||||||
inputEl.placeholder = placeholder;
|
inputEl.placeholder = placeholder;
|
||||||
inputEl.type = input === 'password' ? 'password' : 'text';
|
inputEl.type = input === 'password' ? 'password' : 'text';
|
||||||
|
}
|
||||||
|
|
||||||
okBtn.textContent = okLabel;
|
okBtn.textContent = okLabel;
|
||||||
okBtn.classList.toggle('border-t-danger', danger);
|
okBtn.classList.toggle('border-t-danger', danger);
|
||||||
okBtn.classList.toggle('text-t-danger', danger);
|
okBtn.classList.toggle('text-t-danger', danger);
|
||||||
@@ -41,7 +144,7 @@ export function showDialog({ title, message = '', input = false, placeholder = '
|
|||||||
cancelBtn.classList.toggle('hidden', !cancelLabel);
|
cancelBtn.classList.toggle('hidden', !cancelLabel);
|
||||||
if (closeBtn) closeBtn.classList.toggle('hidden', !cancelLabel);
|
if (closeBtn) closeBtn.classList.toggle('hidden', !cancelLabel);
|
||||||
overlay.classList.add('open');
|
overlay.classList.add('open');
|
||||||
if (input) inputEl.focus(); else okBtn.focus();
|
if (input && !dpMode) inputEl.focus(); else okBtn.focus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { $, api, escRe, getCsrf } from './util.js';
|
import { $, api, escRe, getCsrf } from './util.js';
|
||||||
import { state, getChildren, CARD_COLORS } from './state.js';
|
import { state, getChildren, CARD_COLORS } from './state.js';
|
||||||
import { renderMd, addCodeCopyButtons } from './markdown.js';
|
import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
||||||
import { pushCardEdit, snapshotCard } from './undo.js';
|
import { pushCardEdit, snapshotCard } from './undo.js';
|
||||||
import { fuzzyMatch } from './util.js';
|
import { fuzzyMatch } from './util.js';
|
||||||
import { esc } from './util.js';
|
import { esc } from './util.js';
|
||||||
@@ -151,6 +151,9 @@ export function openModal(card = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
inpTitle.addEventListener('input', updatePreview);
|
inpTitle.addEventListener('input', updatePreview);
|
||||||
|
inpTitle.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); body.focus(); }
|
||||||
|
});
|
||||||
|
|
||||||
// ── Line numbers ──
|
// ── Line numbers ──
|
||||||
|
|
||||||
@@ -313,6 +316,15 @@ function updatePreview() {
|
|||||||
const title = inpTitle.value.trim();
|
const title = inpTitle.value.trim();
|
||||||
previewContent.innerHTML = (title ? `<h1>${esc(title)}</h1>` : '') + renderMd(body.value);
|
previewContent.innerHTML = (title ? `<h1>${esc(title)}</h1>` : '') + renderMd(body.value);
|
||||||
addCodeCopyButtons(previewContent);
|
addCodeCopyButtons(previewContent);
|
||||||
|
formatTodoItems(previewContent);
|
||||||
|
// Interactive checkboxes in editor preview — toggle in textarea
|
||||||
|
previewContent.querySelectorAll('.todo-item').forEach((item, idx) => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
let n = 0;
|
||||||
|
body.value = body.value.replace(/- \[([ x])\]/g, (m, s) => n++ === idx ? `- [${s === 'x' ? ' ' : 'x'}]` : m);
|
||||||
|
updatePreview();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSave(close, { animate = true } = {}) {
|
async function doSave(close, { animate = true } = {}) {
|
||||||
@@ -381,7 +393,32 @@ $('md-toolbar')?.addEventListener('click', e => {
|
|||||||
code: () => insertMd('`', '`', 'code'), codeblock: () => insertMd('```\n', '\n```', 'code'),
|
code: () => insertMd('`', '`', 'code'), codeblock: () => insertMd('```\n', '\n```', 'code'),
|
||||||
ul: () => insertMd('- ', '', 'item'), ol: () => insertMd('1. ', '', 'item'),
|
ul: () => insertMd('- ', '', 'item'), ol: () => insertMd('1. ', '', 'item'),
|
||||||
tree: () => insertMd('```tree\n- ', '\n - child\n', 'parent'),
|
tree: () => insertMd('```tree\n- ', '\n - child\n', 'parent'),
|
||||||
quote: () => insertMd('> ', '', 'quote'), todo: () => insertMd('- [ ] ', '', 'task'),
|
quote: () => insertMd('> ', '', 'quote'),
|
||||||
|
todo: () => {
|
||||||
|
const s = body.selectionStart, e = body.selectionEnd;
|
||||||
|
const sel = body.value.substring(s, e);
|
||||||
|
if (sel && sel.includes('\n') || (sel && /^[\s]*[-*+]\s/m.test(sel))) {
|
||||||
|
// Multi-line or bullet list: convert each line
|
||||||
|
const lines = sel.split('\n');
|
||||||
|
const hasTodos = lines.some(l => /^\s*- \[[ x]\]/.test(l));
|
||||||
|
const converted = lines.map(l => {
|
||||||
|
const trimmed = l.trim();
|
||||||
|
if (!trimmed) return l;
|
||||||
|
if (hasTodos) {
|
||||||
|
// Toggle back: remove checkbox
|
||||||
|
return l.replace(/^(\s*)- \[[ x]\] /, '$1- ');
|
||||||
|
}
|
||||||
|
// Add checkbox: strip existing bullet if present
|
||||||
|
return l.replace(/^(\s*)(?:[-*+]|\d+\.)\s/, '$1').replace(/^(\s*)(.+)$/, '$1- [ ] $2');
|
||||||
|
}).join('\n');
|
||||||
|
body.setRangeText(converted, s, e, 'select');
|
||||||
|
body.selectionStart = s;
|
||||||
|
body.selectionEnd = s + converted.length;
|
||||||
|
updatePreview();
|
||||||
|
} else {
|
||||||
|
insertMd('- [ ] ', '', 'task');
|
||||||
|
}
|
||||||
|
},
|
||||||
link: () => insertMd('[', '](url)', 'text'),
|
link: () => insertMd('[', '](url)', 'text'),
|
||||||
cardlink: () => { insertMd('[[', ']]', ''); showLinkAc(''); },
|
cardlink: () => { insertMd('[[', ']]', ''); showLinkAc(''); },
|
||||||
extract: async () => {
|
extract: async () => {
|
||||||
@@ -719,10 +756,17 @@ document.addEventListener('click', e => {
|
|||||||
if (tagAcVisible && !tagAc.contains(e.target) && e.target !== body) hideTagAc();
|
if (tagAcVisible && !tagAc.contains(e.target) && e.target !== body) hideTagAc();
|
||||||
if (snippetAcVisible && !snippetAc.contains(e.target) && e.target !== body) hideSnippetAc();
|
if (snippetAcVisible && !snippetAc.contains(e.target) && e.target !== body) hideSnippetAc();
|
||||||
});
|
});
|
||||||
|
// Capture phase: intercept ESC before dialog overlay can close the modal
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if (tagAcVisible && e.key === 'Escape') hideTagAc();
|
if (e.key === 'Escape' && (tagAcVisible || snippetAcVisible || linkAcVisible)) {
|
||||||
if (snippetAcVisible && e.key === 'Escape') hideSnippetAc();
|
e.stopImmediatePropagation();
|
||||||
});
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
if (linkAcVisible) hideLinkAc();
|
||||||
|
if (tagAcVisible) hideTagAc();
|
||||||
|
if (snippetAcVisible) hideSnippetAc();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
function completeTagAc(idx) {
|
function completeTagAc(idx) {
|
||||||
const tag = tagAcItems[idx];
|
const tag = tagAcItems[idx];
|
||||||
@@ -828,6 +872,33 @@ body.addEventListener('keydown', e => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === 'Tab') { e.preventDefault(); insertMd(' '); }
|
if (e.key === 'Tab') { e.preventDefault(); insertMd(' '); }
|
||||||
|
// Enter after todo/bullet/numbered list: continue the prefix
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
const pos = body.selectionStart;
|
||||||
|
const before = body.value.substring(0, pos);
|
||||||
|
const lineStart = before.lastIndexOf('\n') + 1;
|
||||||
|
const line = before.substring(lineStart);
|
||||||
|
const m = line.match(/^(\s*)(- \[[ x]\] |[-*+] |\d+\. )/);
|
||||||
|
if (m) {
|
||||||
|
e.preventDefault();
|
||||||
|
const indent = m[1];
|
||||||
|
let prefix = m[2];
|
||||||
|
// If current line is empty (just prefix), clear it instead
|
||||||
|
if (line.trim() === prefix.trim()) {
|
||||||
|
body.setRangeText('', lineStart, pos, 'end');
|
||||||
|
body.selectionStart = body.selectionEnd = lineStart;
|
||||||
|
updatePreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Increment number for ordered lists
|
||||||
|
const numMatch = prefix.match(/^(\d+)\. $/);
|
||||||
|
if (numMatch) prefix = (parseInt(numMatch[1]) + 1) + '. ';
|
||||||
|
body.setRangeText('\n' + indent + prefix, pos, pos, 'end');
|
||||||
|
updatePreview();
|
||||||
|
if (linesVisible) updateGutter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
body.addEventListener('blur', () => { setTimeout(hideLinkAc, 150); setTimeout(hideTagAc, 150); setTimeout(hideSnippetAc, 150); });
|
body.addEventListener('blur', () => { setTimeout(hideLinkAc, 150); setTimeout(hideTagAc, 150); setTimeout(hideSnippetAc, 150); });
|
||||||
|
|||||||
@@ -98,6 +98,34 @@ export function addCodeCopyButtons(container) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatTodoItems(container) {
|
||||||
|
if (!container) return;
|
||||||
|
container.querySelectorAll('li').forEach(li => {
|
||||||
|
const cb = li.querySelector('input[type="checkbox"]');
|
||||||
|
if (!cb) return;
|
||||||
|
if (li.classList.contains('todo-item')) return;
|
||||||
|
|
||||||
|
const checked = cb.checked;
|
||||||
|
li.classList.add('todo-item');
|
||||||
|
if (checked) li.classList.add('todo-done');
|
||||||
|
|
||||||
|
// Replace <input> with a styled button
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'todo-cb' + (checked ? ' checked' : '');
|
||||||
|
btn.innerHTML = '<i class="ph ph-check"></i>';
|
||||||
|
btn.dataset.idx = cb.dataset.idx || '';
|
||||||
|
cb.replaceWith(btn);
|
||||||
|
|
||||||
|
// Wrap remaining content in a span
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'todo-text';
|
||||||
|
while (btn.nextSibling) span.appendChild(btn.nextSibling);
|
||||||
|
li.appendChild(span);
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function stripMd(src) {
|
export function stripMd(src) {
|
||||||
return (src || '')
|
return (src || '')
|
||||||
.replace(/<img[^>]*>/gi, '')
|
.replace(/<img[^>]*>/gi, '')
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// ── Search ──
|
// ── Search ──
|
||||||
|
|
||||||
import { $, esc, escRe, fuzzyMatch, countSubstring, highlight } from './util.js';
|
import { $, esc, escRe, api, fuzzyMatch, countSubstring, highlight } from './util.js';
|
||||||
import { state, parseTags, tagChipHtml } from './state.js';
|
import { state, parseTags, tagChipHtml, getChildren } from './state.js';
|
||||||
import { renderMd, stripMd } from './markdown.js';
|
import { renderMd, stripMd } from './markdown.js';
|
||||||
|
|
||||||
const overlay = $('search');
|
const overlay = $('search');
|
||||||
@@ -170,9 +170,12 @@ function hitHtml(card, idx, words) {
|
|||||||
} else { snippet = esc(plain.substring(0, 120)) + (plain.length > 120 ? '...' : ''); }
|
} else { snippet = esc(plain.substring(0, 120)) + (plain.length > 120 ? '...' : ''); }
|
||||||
|
|
||||||
const htags = parseTags(card);
|
const htags = parseTags(card);
|
||||||
return `<div class="search-hit flex items-start gap-3 px-4 py-2.5 cursor-pointer transition-colors hover:bg-t-raised" data-idx="${idx}">
|
const children = getChildren(card.id);
|
||||||
|
const isGroup = children.length > 0;
|
||||||
|
const canDrag = !isGroup && !card.parent_id;
|
||||||
|
return `<div class="search-hit flex items-start gap-3 px-4 py-2.5 cursor-pointer transition-colors hover:bg-t-raised" data-idx="${idx}" data-card-id="${card.id}" ${canDrag ? 'draggable="true"' : ''}>
|
||||||
<span class="w-2 h-2 rounded-full shrink-0 mt-1.5" style="background:${card.color}"></span>
|
<span class="w-2 h-2 rounded-full shrink-0 mt-1.5" style="background:${card.color}"></span>
|
||||||
<div class="min-w-0 flex-1"><div class="text-xs text-t-bright font-pixel leading-relaxed truncate">${title}</div>
|
<div class="min-w-0 flex-1"><div class="text-xs text-t-bright font-pixel leading-relaxed truncate flex items-center gap-2">${title}${isGroup ? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:${card.color};color:#000;" class="font-pixel">${children.length + 1}</span>` : ''}</div>
|
||||||
<div class="text-[11px] text-t-muted leading-relaxed mt-0.5 line-clamp-2">${snippet}</div>
|
<div class="text-[11px] text-t-muted leading-relaxed mt-0.5 line-clamp-2">${snippet}</div>
|
||||||
${htags.length ? `<div class="flex flex-wrap gap-1 mt-1">${htags.slice(0, 3).map(t => `<span class="tag-chip" style="font-size:8px;padding:0 4px;">#${esc(t)}</span>`).join('')}</div>` : ''}</div></div>`;
|
${htags.length ? `<div class="flex flex-wrap gap-1 mt-1">${htags.slice(0, 3).map(t => `<span class="tag-chip" style="font-size:8px;padding:0 4px;">#${esc(t)}</span>`).join('')}</div>` : ''}</div></div>`;
|
||||||
}
|
}
|
||||||
@@ -185,6 +188,42 @@ function bindHits() {
|
|||||||
else { active = i; updateActive(); }
|
else { active = i; updateActive(); }
|
||||||
});
|
});
|
||||||
el.addEventListener('dblclick', () => { const i = parseInt(el.dataset.idx); if (hits[i]) window._openDetail(hits[i].id); });
|
el.addEventListener('dblclick', () => { const i = parseInt(el.dataset.idx); if (hits[i]) window._openDetail(hits[i].id); });
|
||||||
|
|
||||||
|
// Drag: only non-group, non-child cards
|
||||||
|
if (el.draggable) {
|
||||||
|
el.addEventListener('dragstart', e => {
|
||||||
|
e.dataTransfer.setData('text/plain', el.dataset.cardId);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
el.style.opacity = '0.4';
|
||||||
|
});
|
||||||
|
el.addEventListener('dragend', () => { el.style.opacity = ''; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop target: any card can receive (to form group)
|
||||||
|
el.addEventListener('dragover', e => {
|
||||||
|
const dragId = e.dataTransfer.types.includes('text/plain') ? true : false;
|
||||||
|
if (!dragId) return;
|
||||||
|
if (el.dataset.cardId === e.dataTransfer.getData('text/plain')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
el.style.background = 'var(--t-raised)';
|
||||||
|
el.style.borderLeft = `3px solid var(--t-accent)`;
|
||||||
|
});
|
||||||
|
el.addEventListener('dragleave', () => {
|
||||||
|
el.style.background = '';
|
||||||
|
el.style.borderLeft = '';
|
||||||
|
});
|
||||||
|
el.addEventListener('drop', async e => {
|
||||||
|
el.style.background = '';
|
||||||
|
el.style.borderLeft = '';
|
||||||
|
const dragId = e.dataTransfer.getData('text/plain');
|
||||||
|
const targetId = el.dataset.cardId;
|
||||||
|
if (!dragId || !targetId || dragId === targetId) return;
|
||||||
|
e.preventDefault();
|
||||||
|
await api(`api/cards/${dragId}`, { method: 'PATCH', body: { parent_id: targetId } });
|
||||||
|
await window._reloadAll();
|
||||||
|
search(input.value);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,8 @@ class CalDavClient
|
|||||||
$block = $m[1];
|
$block = $m[1];
|
||||||
|
|
||||||
$get = function ($prop) use ($block) {
|
$get = function ($prop) use ($block) {
|
||||||
if (preg_match('/^' . preg_quote($prop, '/') . '[^:]*:(.+)$/m', $block, $m)) return trim($m[1]);
|
// Match property value: everything after "PROP...:" until the next property or end
|
||||||
|
if (preg_match('/^' . preg_quote($prop, '/') . '[^:]*:([\s\S]*?)(?=\n[A-Z][-A-Z]*[;:]|\z)/m', $block, $m)) return trim($m[1]);
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+6
-4
@@ -171,7 +171,7 @@ class CalDavSync
|
|||||||
$location = '';
|
$location = '';
|
||||||
if (preg_match('/\*\*Ort:\*\*\s*(.+)/i', $body, $lm)) $location = trim($lm[1]);
|
if (preg_match('/\*\*Ort:\*\*\s*(.+)/i', $body, $lm)) $location = trim($lm[1]);
|
||||||
$description = '';
|
$description = '';
|
||||||
if (preg_match('/\*\*Notizen:\*\*\s*\n([\s\S]*?)(?:\n\n#|\z)/i', $body, $nm)) $description = trim($nm[1]);
|
if (preg_match('/\*\*Notizen:\*\*\s*\n([\s\S]*?)(?=\n#[a-z]|\z)/i', $body, $nm)) $description = trim($nm[1]);
|
||||||
|
|
||||||
// Build DTSTART/DTEND
|
// Build DTSTART/DTEND
|
||||||
$dateCompact = str_replace('-', '', $date);
|
$dateCompact = str_replace('-', '', $date);
|
||||||
@@ -428,8 +428,11 @@ class CalDavSync
|
|||||||
if (isset($remoteMap[$uid])) {
|
if (isset($remoteMap[$uid])) {
|
||||||
$remote = $remoteMap[$uid];
|
$remote = $remoteMap[$uid];
|
||||||
$remoteClient = $this->makeClient($remote['cal_url']);
|
$remoteClient = $this->makeClient($remote['cal_url']);
|
||||||
$localChanged = !$card['caldav_synced_at'] || $card['updated_at'] > $card['caldav_synced_at'];
|
// Compare timestamps with 2s tolerance to avoid false positives from sync-triggered updates
|
||||||
$remoteChanged = $card['caldav_etag'] !== $remote['etag'];
|
$localTime = strtotime($card['updated_at'] ?? '2000-01-01');
|
||||||
|
$syncTime = strtotime($card['caldav_synced_at'] ?? '2000-01-01');
|
||||||
|
$localChanged = !$card['caldav_synced_at'] || ($localTime - $syncTime) > 2;
|
||||||
|
$remoteChanged = !$card['caldav_etag'] || $card['caldav_etag'] !== $remote['etag'];
|
||||||
|
|
||||||
// Always backfill rrule if missing locally but present remotely
|
// Always backfill rrule if missing locally but present remotely
|
||||||
$remoteRRule = $remote['rrule'] ?? null;
|
$remoteRRule = $remote['rrule'] ?? null;
|
||||||
@@ -440,7 +443,6 @@ class CalDavSync
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($localChanged && $remoteChanged) {
|
if ($localChanged && $remoteChanged) {
|
||||||
$localTime = strtotime($card['updated_at']);
|
|
||||||
$remoteTime = $remote['last_modified'] ? strtotime($remote['last_modified']) : 0;
|
$remoteTime = $remote['last_modified'] ? strtotime($remote['last_modified']) : 0;
|
||||||
if ($localTime >= $remoteTime) {
|
if ($localTime >= $remoteTime) {
|
||||||
$this->pushCard($remoteClient, $card, $remote['href'], $remote['etag']);
|
$this->pushCard($remoteClient, $card, $remote['href'], $remote['etag']);
|
||||||
|
|||||||
Reference in new issue
Block a user