feat: smart video size warnings + Jinja2 template autocomplete
Video size warnings:
- Add file_size field to ImmichAssetInfo from exifInfo.fileSizeInByte
- Expose per-target max_video_size (50 MB for Telegram, none for others)
- Compute has_oversized_videos and per-asset oversized flag in template context
- Default templates show warning only when videos actually exceed the limit
- Templates no longer hardcode Telegram-specific logic
Template autocomplete:
- New jinja-autocomplete.ts engine with contextual completions
- Top-level variables ({{ }}), asset/album fields (dot access in loops),
Jinja2 filters (|), block tags ({% %}), and loop.* special vars
- JinjaEditor accepts optional variables prop via CodeMirror Compartment
- Wired into template-configs and command-template-configs pages
Also: fix template emoji (📷 → 📎) and sync sample_context with new vars.
This commit is contained in:
Generated
+1
@@ -8,6 +8,7 @@
|
|||||||
"name": "notify-bridge-frontend",
|
"name": "notify-bridge-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.18.0",
|
||||||
"@codemirror/lang-html": "^6.4.11",
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
"@codemirror/language": "^6.12.2",
|
"@codemirror/language": "^6.12.2",
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.18.0",
|
||||||
"@codemirror/lang-html": "^6.4.11",
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
"@codemirror/language": "^6.12.2",
|
"@codemirror/language": "^6.12.2",
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { EditorView, Decoration, placeholder as cmPlaceholder, type DecorationSet } from '@codemirror/view';
|
import { EditorView, Decoration, keymap, placeholder as cmPlaceholder, type DecorationSet } from '@codemirror/view';
|
||||||
import { EditorState, StateField, StateEffect } from '@codemirror/state';
|
import { EditorState, StateField, StateEffect, Compartment } from '@codemirror/state';
|
||||||
import { StreamLanguage } from '@codemirror/language';
|
import { StreamLanguage } from '@codemirror/language';
|
||||||
import { oneDark } from '@codemirror/theme-one-dark';
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
|
import { acceptCompletion } from '@codemirror/autocomplete';
|
||||||
import { getTheme } from '$lib/theme.svelte';
|
import { getTheme } from '$lib/theme.svelte';
|
||||||
|
import { jinjaAutocomplete, type SlotVariables } from '$lib/editor/jinja-autocomplete';
|
||||||
|
|
||||||
let { value = '', onchange, rows = 6, placeholder = '', errorLine = null } = $props<{
|
let { value = '', onchange, rows = 6, placeholder = '', errorLine = null, variables = undefined } = $props<{
|
||||||
value: string;
|
value: string;
|
||||||
onchange: (val: string) => void;
|
onchange: (val: string) => void;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
errorLine?: number | null;
|
errorLine?: number | null;
|
||||||
|
variables?: SlotVariables;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const autocompleteCompartment = new Compartment();
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let view: EditorView;
|
let view: EditorView;
|
||||||
const theme = getTheme();
|
const theme = getTheme();
|
||||||
@@ -71,6 +76,8 @@
|
|||||||
const extensions = [
|
const extensions = [
|
||||||
jinjaLang,
|
jinjaLang,
|
||||||
errorLineField,
|
errorLineField,
|
||||||
|
autocompleteCompartment.of(variables ? jinjaAutocomplete(variables) : []),
|
||||||
|
keymap.of([{ key: 'Tab', run: acceptCompletion }]),
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged) {
|
if (update.docChanged) {
|
||||||
onchange(update.state.doc.toString());
|
onchange(update.state.doc.toString());
|
||||||
@@ -86,6 +93,11 @@
|
|||||||
'.ͼc': { color: '#e879f9' },
|
'.ͼc': { color: '#e879f9' },
|
||||||
'.ͼd': { color: '#38bdf8' },
|
'.ͼd': { color: '#38bdf8' },
|
||||||
'.ͼ5': { color: '#6b7280' },
|
'.ͼ5': { color: '#6b7280' },
|
||||||
|
'.cm-tooltip-autocomplete': {
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
fontSize: '12px',
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
if (isDark) extensions.push(oneDark);
|
if (isDark) extensions.push(oneDark);
|
||||||
@@ -116,6 +128,17 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update autocomplete when variables change
|
||||||
|
$effect(() => {
|
||||||
|
if (view) {
|
||||||
|
view.dispatch({
|
||||||
|
effects: autocompleteCompartment.reconfigure(
|
||||||
|
variables ? jinjaAutocomplete(variables) : [],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Recreate editor when theme changes
|
// Recreate editor when theme changes
|
||||||
let lastIsDark: boolean | undefined;
|
let lastIsDark: boolean | undefined;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* Jinja2-aware autocomplete for CodeMirror 6.
|
||||||
|
*
|
||||||
|
* Provides contextual suggestions for:
|
||||||
|
* - Top-level template variables inside {{ }} and {% %}
|
||||||
|
* - Asset/album/commit fields after dot access inside {% for %} loops
|
||||||
|
* - Jinja2 loop special variables (loop.index, loop.first, etc.)
|
||||||
|
* - Jinja2 filters after |
|
||||||
|
* - Jinja2 block tags after {%
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
autocompletion,
|
||||||
|
type Completion,
|
||||||
|
type CompletionContext,
|
||||||
|
type CompletionResult,
|
||||||
|
} from '@codemirror/autocomplete';
|
||||||
|
import type { Extension } from '@codemirror/state';
|
||||||
|
|
||||||
|
/** Shape of per-slot variable metadata from the API. */
|
||||||
|
export interface SlotVariables {
|
||||||
|
variables: Record<string, string>;
|
||||||
|
asset_fields?: Record<string, string>;
|
||||||
|
album_fields?: Record<string, string>;
|
||||||
|
commit_fields?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known collection → field set mappings
|
||||||
|
const COLLECTION_FIELD_MAP: Record<string, keyof SlotVariables> = {
|
||||||
|
added_assets: 'asset_fields',
|
||||||
|
assets: 'asset_fields',
|
||||||
|
removed_assets: 'asset_fields',
|
||||||
|
collections: 'album_fields',
|
||||||
|
albums: 'album_fields',
|
||||||
|
commits: 'commit_fields',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Jinja2 loop special variables
|
||||||
|
const LOOP_VARS: Completion[] = [
|
||||||
|
{ label: 'index', type: 'property', detail: 'Current iteration (1-indexed)' },
|
||||||
|
{ label: 'index0', type: 'property', detail: 'Current iteration (0-indexed)' },
|
||||||
|
{ label: 'first', type: 'property', detail: 'True on first iteration' },
|
||||||
|
{ label: 'last', type: 'property', detail: 'True on last iteration' },
|
||||||
|
{ label: 'length', type: 'property', detail: 'Total number of items' },
|
||||||
|
{ label: 'revindex', type: 'property', detail: 'Iterations until end (1-indexed)' },
|
||||||
|
{ label: 'revindex0', type: 'property', detail: 'Iterations until end (0-indexed)' },
|
||||||
|
{ label: 'cycle', type: 'function', detail: 'Cycle through values' },
|
||||||
|
{ label: 'depth', type: 'property', detail: 'Nesting level (starts at 0)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Common Jinja2 filters
|
||||||
|
const JINJA_FILTERS: Completion[] = [
|
||||||
|
{ label: 'join', type: 'function', detail: 'Join list with separator' },
|
||||||
|
{ label: 'length', type: 'function', detail: 'Number of items' },
|
||||||
|
{ label: 'default', type: 'function', detail: 'Default value if undefined' },
|
||||||
|
{ label: 'upper', type: 'function', detail: 'Uppercase string' },
|
||||||
|
{ label: 'lower', type: 'function', detail: 'Lowercase string' },
|
||||||
|
{ label: 'title', type: 'function', detail: 'Titlecase string' },
|
||||||
|
{ label: 'capitalize', type: 'function', detail: 'Capitalize first char' },
|
||||||
|
{ label: 'trim', type: 'function', detail: 'Strip whitespace' },
|
||||||
|
{ label: 'replace', type: 'function', detail: 'Replace substring' },
|
||||||
|
{ label: 'int', type: 'function', detail: 'Convert to integer' },
|
||||||
|
{ label: 'float', type: 'function', detail: 'Convert to float' },
|
||||||
|
{ label: 'round', type: 'function', detail: 'Round number' },
|
||||||
|
{ label: 'first', type: 'function', detail: 'First item of list' },
|
||||||
|
{ label: 'last', type: 'function', detail: 'Last item of list' },
|
||||||
|
{ label: 'sort', type: 'function', detail: 'Sort list' },
|
||||||
|
{ label: 'reverse', type: 'function', detail: 'Reverse list' },
|
||||||
|
{ label: 'batch', type: 'function', detail: 'Split into batches' },
|
||||||
|
{ label: 'filesizeformat', type: 'function', detail: 'Human-readable file size' },
|
||||||
|
{ label: 'truncate', type: 'function', detail: 'Truncate string' },
|
||||||
|
{ label: 'wordcount', type: 'function', detail: 'Count words' },
|
||||||
|
{ label: 'e', type: 'function', detail: 'HTML escape' },
|
||||||
|
{ label: 'safe', type: 'function', detail: 'Mark as safe HTML' },
|
||||||
|
{ label: 'striptags', type: 'function', detail: 'Strip HTML tags' },
|
||||||
|
{ label: 'urlencode', type: 'function', detail: 'URL-encode string' },
|
||||||
|
{ label: 'list', type: 'function', detail: 'Convert to list' },
|
||||||
|
{ label: 'string', type: 'function', detail: 'Convert to string' },
|
||||||
|
{ label: 'abs', type: 'function', detail: 'Absolute value' },
|
||||||
|
{ label: 'max', type: 'function', detail: 'Maximum value' },
|
||||||
|
{ label: 'min', type: 'function', detail: 'Minimum value' },
|
||||||
|
{ label: 'map', type: 'function', detail: 'Apply to each item' },
|
||||||
|
{ label: 'select', type: 'function', detail: 'Filter items by test' },
|
||||||
|
{ label: 'reject', type: 'function', detail: 'Remove items by test' },
|
||||||
|
{ label: 'unique', type: 'function', detail: 'Remove duplicates' },
|
||||||
|
{ label: 'groupby', type: 'function', detail: 'Group items by attribute' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Jinja2 block tags
|
||||||
|
const BLOCK_TAGS: Completion[] = [
|
||||||
|
{ label: 'for', type: 'keyword', detail: 'Loop over items' },
|
||||||
|
{ label: 'endfor', type: 'keyword', detail: 'End for loop' },
|
||||||
|
{ label: 'if', type: 'keyword', detail: 'Conditional block' },
|
||||||
|
{ label: 'elif', type: 'keyword', detail: 'Else-if branch' },
|
||||||
|
{ label: 'else', type: 'keyword', detail: 'Else branch' },
|
||||||
|
{ label: 'endif', type: 'keyword', detail: 'End if block' },
|
||||||
|
{ label: 'set', type: 'keyword', detail: 'Assign variable' },
|
||||||
|
{ label: 'block', type: 'keyword', detail: 'Template block' },
|
||||||
|
{ label: 'endblock', type: 'keyword', detail: 'End block' },
|
||||||
|
{ label: 'macro', type: 'keyword', detail: 'Define macro' },
|
||||||
|
{ label: 'endmacro', type: 'keyword', detail: 'End macro' },
|
||||||
|
{ label: 'raw', type: 'keyword', detail: 'Raw output (no rendering)' },
|
||||||
|
{ label: 'endraw', type: 'keyword', detail: 'End raw block' },
|
||||||
|
{ label: 'filter', type: 'keyword', detail: 'Apply filter to block' },
|
||||||
|
{ label: 'endfilter', type: 'keyword', detail: 'End filter block' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Build completions from a name→description record. */
|
||||||
|
function toCompletions(
|
||||||
|
vars: Record<string, string>,
|
||||||
|
type: string,
|
||||||
|
): Completion[] {
|
||||||
|
return Object.entries(vars).map(([label, detail]) => ({ label, type, detail }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan backwards from cursor to find the nearest `{% for <var> in <collection> %}`
|
||||||
|
* that binds `identifier` as the loop variable.
|
||||||
|
* Returns the collection name or null.
|
||||||
|
*/
|
||||||
|
function findLoopCollection(doc: string, identifier: string): string | null {
|
||||||
|
// Search up to 3000 chars back for performance
|
||||||
|
const searchText = doc.slice(Math.max(0, doc.length - 3000));
|
||||||
|
const re = new RegExp(
|
||||||
|
`\\{%-?\\s*for\\s+${escapeRegExp(identifier)}\\s+in\\s+(\\w+)`,
|
||||||
|
'g',
|
||||||
|
);
|
||||||
|
let lastMatch: string | null = null;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(searchText)) !== null) {
|
||||||
|
lastMatch = m[1];
|
||||||
|
}
|
||||||
|
return lastMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if cursor is inside a Jinja2 delimiter ({{ }}, {% %}, or {# #}).
|
||||||
|
* Returns the delimiter type or null.
|
||||||
|
*/
|
||||||
|
function getJinjaContext(
|
||||||
|
textBefore: string,
|
||||||
|
): 'expression' | 'block' | 'comment' | null {
|
||||||
|
// Find the last opening delimiter
|
||||||
|
let lastExpr = textBefore.lastIndexOf('{{');
|
||||||
|
let lastBlock = textBefore.lastIndexOf('{%');
|
||||||
|
let lastComment = textBefore.lastIndexOf('{#');
|
||||||
|
|
||||||
|
// Find the last closing delimiter
|
||||||
|
const lastCloseExpr = textBefore.lastIndexOf('}}');
|
||||||
|
const lastCloseBlock = textBefore.lastIndexOf('%}');
|
||||||
|
const lastCloseComment = textBefore.lastIndexOf('#}');
|
||||||
|
|
||||||
|
// Check if we're inside an expression {{ }}
|
||||||
|
if (lastExpr > lastCloseExpr && lastExpr >= lastBlock && lastExpr >= lastComment) {
|
||||||
|
return 'expression';
|
||||||
|
}
|
||||||
|
// Check if we're inside a block {% %}
|
||||||
|
if (lastBlock > lastCloseBlock && lastBlock >= lastExpr && lastBlock >= lastComment) {
|
||||||
|
return 'block';
|
||||||
|
}
|
||||||
|
// Check if we're inside a comment {# #}
|
||||||
|
if (lastComment > lastCloseComment && lastComment >= lastExpr && lastComment >= lastBlock) {
|
||||||
|
return 'comment';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function completionSource(
|
||||||
|
vars: SlotVariables,
|
||||||
|
): (ctx: CompletionContext) => CompletionResult | null {
|
||||||
|
const topLevelCompletions = toCompletions(vars.variables, 'variable');
|
||||||
|
|
||||||
|
return (ctx: CompletionContext): CompletionResult | null => {
|
||||||
|
const textBefore = ctx.state.doc.sliceString(0, ctx.pos);
|
||||||
|
const lineBefore = ctx.state.doc.lineAt(ctx.pos);
|
||||||
|
const lineTextBefore = lineBefore.text.slice(0, ctx.pos - lineBefore.from);
|
||||||
|
|
||||||
|
// 1. Filter completions: after |
|
||||||
|
const filterMatch = lineTextBefore.match(/\|\s*(\w*)$/);
|
||||||
|
if (filterMatch) {
|
||||||
|
return {
|
||||||
|
from: ctx.pos - filterMatch[1].length,
|
||||||
|
options: JINJA_FILTERS,
|
||||||
|
validFor: /^\w*$/,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Dot-access completions: identifier.partial
|
||||||
|
const dotMatch = lineTextBefore.match(/(\w+)\.(\w*)$/);
|
||||||
|
if (dotMatch) {
|
||||||
|
const [, identifier, partial] = dotMatch;
|
||||||
|
|
||||||
|
// loop.xxx
|
||||||
|
if (identifier === 'loop') {
|
||||||
|
return {
|
||||||
|
from: ctx.pos - partial.length,
|
||||||
|
options: LOOP_VARS,
|
||||||
|
validFor: /^\w*$/,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find what collection this variable iterates over
|
||||||
|
const collection = findLoopCollection(textBefore, identifier);
|
||||||
|
if (collection) {
|
||||||
|
const fieldKey = COLLECTION_FIELD_MAP[collection];
|
||||||
|
const fields = fieldKey ? vars[fieldKey] : undefined;
|
||||||
|
if (fields && typeof fields === 'object') {
|
||||||
|
return {
|
||||||
|
from: ctx.pos - partial.length,
|
||||||
|
options: toCompletions(fields, 'property'),
|
||||||
|
validFor: /^\w*$/,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jinjaCtx = getJinjaContext(textBefore);
|
||||||
|
if (!jinjaCtx || jinjaCtx === 'comment') return null;
|
||||||
|
|
||||||
|
// 3. Block tag completions: {% partial
|
||||||
|
if (jinjaCtx === 'block') {
|
||||||
|
const tagMatch = lineTextBefore.match(/\{%-?\s*(\w*)$/);
|
||||||
|
if (tagMatch) {
|
||||||
|
const partial = tagMatch[1];
|
||||||
|
// If we're past the tag keyword, suggest variables (e.g. {% if var %})
|
||||||
|
const afterTag = lineTextBefore.match(/\{%-?\s*(?:if|elif|for\s+\w+\s+in)\s+(\w*)$/);
|
||||||
|
if (afterTag) {
|
||||||
|
return {
|
||||||
|
from: ctx.pos - afterTag[1].length,
|
||||||
|
options: topLevelCompletions,
|
||||||
|
validFor: /^\w*$/,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
from: ctx.pos - partial.length,
|
||||||
|
options: BLOCK_TAGS,
|
||||||
|
validFor: /^\w*$/,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Expression variable completions: {{ partial
|
||||||
|
if (jinjaCtx === 'expression') {
|
||||||
|
const exprMatch = lineTextBefore.match(/\{\{-?\s*(\w*)$/);
|
||||||
|
if (exprMatch) {
|
||||||
|
return {
|
||||||
|
from: ctx.pos - exprMatch[1].length,
|
||||||
|
options: topLevelCompletions,
|
||||||
|
validFor: /^\w*$/,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Also handle after spaces inside expression (e.g. {{ var if condition }})
|
||||||
|
const wordMatch = lineTextBefore.match(/\s(\w*)$/);
|
||||||
|
if (wordMatch) {
|
||||||
|
return {
|
||||||
|
from: ctx.pos - wordMatch[1].length,
|
||||||
|
options: topLevelCompletions,
|
||||||
|
validFor: /^\w*$/,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a CodeMirror autocomplete extension for Jinja2 templates. */
|
||||||
|
export function jinjaAutocomplete(vars: SlotVariables): Extension {
|
||||||
|
return autocompletion({
|
||||||
|
override: [completionSource(vars)],
|
||||||
|
activateOnTyping: true,
|
||||||
|
icons: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -305,6 +305,7 @@
|
|||||||
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
|
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
|
||||||
rows={3}
|
rows={3}
|
||||||
errorLine={slotErrorLines[slot.name] || null}
|
errorLine={slotErrorLines[slot.name] || null}
|
||||||
|
variables={varsRef[slot.name] || undefined}
|
||||||
/>
|
/>
|
||||||
{#if slotErrors[slot.name]}
|
{#if slotErrors[slot.name]}
|
||||||
{#if slotErrorTypes[slot.name] === 'undefined'}
|
{#if slotErrorTypes[slot.name] === 'undefined'}
|
||||||
|
|||||||
@@ -315,7 +315,7 @@
|
|||||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('templateConfig.invalidFormat')}</p>
|
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('templateConfig.invalidFormat')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<JinjaEditor value={form.slots[slot.key] || ''} onchange={(v: string) => { form.slots[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} />
|
<JinjaEditor value={form.slots[slot.key] || ''} onchange={(v: string) => { form.slots[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} variables={varsRef[slot.key] || undefined} />
|
||||||
{#if slotErrors[slot.key]}
|
{#if slotErrors[slot.key]}
|
||||||
{#if slotErrorTypes[slot.key] === 'undefined'}
|
{#if slotErrorTypes[slot.key] === 'undefined'}
|
||||||
<p class="mt-1 text-xs" style="color: #d97706;">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
|
<p class="mt-1 text-xs" style="color: #d97706;">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ def _asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
|
|||||||
"state": asset.state,
|
"state": asset.state,
|
||||||
"country": asset.country,
|
"country": asset.country,
|
||||||
"thumbhash": asset.thumbhash,
|
"thumbhash": asset.thumbhash,
|
||||||
|
"file_size": asset.file_size,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ class ImmichAssetInfo:
|
|||||||
city: str | None = None
|
city: str | None = None
|
||||||
state: str | None = None
|
state: str | None = None
|
||||||
country: str | None = None
|
country: str | None = None
|
||||||
|
file_size: int | None = None # bytes, from exifInfo.fileSizeInByte
|
||||||
is_processed: bool = True
|
is_processed: bool = True
|
||||||
thumbhash: str | None = None
|
thumbhash: str | None = None
|
||||||
|
|
||||||
@@ -105,6 +106,7 @@ class ImmichAssetInfo:
|
|||||||
city = exif_info.get("city") if exif_info else None
|
city = exif_info.get("city") if exif_info else None
|
||||||
state = exif_info.get("state") if exif_info else None
|
state = exif_info.get("state") if exif_info else None
|
||||||
country = exif_info.get("country") if exif_info else None
|
country = exif_info.get("country") if exif_info else None
|
||||||
|
file_size = exif_info.get("fileSizeInByte") if exif_info else None
|
||||||
|
|
||||||
asset_type = data.get("type", ASSET_TYPE_IMAGE)
|
asset_type = data.get("type", ASSET_TYPE_IMAGE)
|
||||||
is_processed = cls._check_processing_status(data)
|
is_processed = cls._check_processing_status(data)
|
||||||
@@ -126,6 +128,7 @@ class ImmichAssetInfo:
|
|||||||
city=city,
|
city=city,
|
||||||
state=state,
|
state=state,
|
||||||
country=country,
|
country=country,
|
||||||
|
file_size=file_size,
|
||||||
is_processed=is_processed,
|
is_processed=is_processed,
|
||||||
thumbhash=thumbhash,
|
thumbhash=thumbhash,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ from typing import Any
|
|||||||
|
|
||||||
from notify_bridge_core.models.events import ServiceEvent
|
from notify_bridge_core.models.events import ServiceEvent
|
||||||
|
|
||||||
|
# Per-target maximum video size (bytes). None = no limit.
|
||||||
|
_MAX_VIDEO_SIZE_BY_TARGET: dict[str, int] = {
|
||||||
|
"telegram": 50 * 1024 * 1024, # 50 MB — Telegram Bot API hard limit
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_template_context(
|
def build_template_context(
|
||||||
event: ServiceEvent,
|
event: ServiceEvent,
|
||||||
@@ -57,6 +62,7 @@ def build_template_context(
|
|||||||
}
|
}
|
||||||
# Flatten extras into asset dict for template access
|
# Flatten extras into asset dict for template access
|
||||||
asset_dict.update(asset.extra)
|
asset_dict.update(asset.extra)
|
||||||
|
asset_dict.setdefault("oversized", False)
|
||||||
assets.append(asset_dict)
|
assets.append(asset_dict)
|
||||||
|
|
||||||
# Enrich assets with per-asset public URLs if album has a public share link
|
# Enrich assets with per-asset public URLs if album has a public share link
|
||||||
@@ -75,6 +81,22 @@ def build_template_context(
|
|||||||
ctx["has_videos"] = any(a.get("type") == "VIDEO" for a in assets)
|
ctx["has_videos"] = any(a.get("type") == "VIDEO" for a in assets)
|
||||||
ctx["has_photos"] = any(a.get("type") == "IMAGE" for a in assets)
|
ctx["has_photos"] = any(a.get("type") == "IMAGE" for a in assets)
|
||||||
|
|
||||||
|
# Per-target video size limit and oversized-video detection
|
||||||
|
max_video_bytes = _MAX_VIDEO_SIZE_BY_TARGET.get(target_type)
|
||||||
|
ctx["max_video_size"] = max_video_bytes # bytes or None
|
||||||
|
ctx["max_video_size_mb"] = max_video_bytes // (1024 * 1024) if max_video_bytes else None
|
||||||
|
|
||||||
|
has_oversized = False
|
||||||
|
if max_video_bytes:
|
||||||
|
for a in assets:
|
||||||
|
if a.get("type") == "VIDEO":
|
||||||
|
fs = a.get("file_size")
|
||||||
|
oversized = fs is not None and fs > max_video_bytes
|
||||||
|
a["oversized"] = oversized
|
||||||
|
if oversized:
|
||||||
|
has_oversized = True
|
||||||
|
ctx["has_oversized_videos"] = has_oversized
|
||||||
|
|
||||||
# Date format strings (available to templates for custom formatting)
|
# Date format strings (available to templates for custom formatting)
|
||||||
ctx["date_format"] = date_format
|
ctx["date_format"] = date_format
|
||||||
ctx["date_only_format"] = date_only_format
|
ctx["date_only_format"] = date_only_format
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
📷 {{ added_count }} new file(s) added to album {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
|
📎 {{ added_count }} new file(s) added to album {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
|
||||||
{%- if common_date %} 📅 {{ common_date }}{% endif %}
|
{%- if common_date %} 📅 {{ common_date }}{% endif %}
|
||||||
{%- if common_location %} 📍 {{ common_location }}{% endif %}
|
{%- if common_location %} 📍 {{ common_location }}{% endif %}
|
||||||
{%- if people %}
|
{%- if people %}
|
||||||
@@ -9,9 +9,10 @@
|
|||||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||||
{%- if not common_location and asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
{%- if not common_location and asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||||
|
{%- if asset.oversized %} ⚠️{% endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- if target_type == "telegram" and has_videos %}
|
{%- if has_oversized_videos %}
|
||||||
|
|
||||||
⚠️ Videos may not be sent due to Telegram's 50 MB file size limit.
|
⚠️ Some videos exceed the {{ max_video_size_mb }} MB file size limit and may not be sent.
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
📷 {{ added_count }} новых файл(ов) добавлено в альбом {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
|
📎 {{ added_count }} новых файл(ов) добавлено в альбом {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
|
||||||
{%- if common_date %} 📅 {{ common_date }}{% endif %}
|
{%- if common_date %} 📅 {{ common_date }}{% endif %}
|
||||||
{%- if common_location %} 📍 {{ common_location }}{% endif %}
|
{%- if common_location %} 📍 {{ common_location }}{% endif %}
|
||||||
{%- if people %}
|
{%- if people %}
|
||||||
@@ -9,9 +9,10 @@
|
|||||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||||
{%- if not common_location and asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
{%- if not common_location and asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||||
|
{%- if asset.oversized %} ⚠️{% endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- if target_type == "telegram" and has_videos %}
|
{%- if has_oversized_videos %}
|
||||||
|
|
||||||
⚠️ Видео может не отправиться из-за ограничения Telegram в 50 МБ.
|
⚠️ Некоторые видео превышают лимит {{ max_video_size_mb }} МБ и могут не отправиться.
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
@@ -24,6 +24,7 @@ def validate_template(
|
|||||||
# Also allow common runtime variables not in the registry
|
# Also allow common runtime variables not in the registry
|
||||||
runtime_vars = {
|
runtime_vars = {
|
||||||
"target_type", "has_videos", "has_photos",
|
"target_type", "has_videos", "has_photos",
|
||||||
|
"has_oversized_videos", "max_video_size", "max_video_size_mb",
|
||||||
"added_assets", "assets", "albums",
|
"added_assets", "assets", "albums",
|
||||||
}
|
}
|
||||||
allowed = available | runtime_vars
|
allowed = available | runtime_vars
|
||||||
|
|||||||
@@ -150,6 +150,9 @@ async def get_template_variables():
|
|||||||
"target_type": "Target type: 'telegram' or 'webhook'",
|
"target_type": "Target type: 'telegram' or 'webhook'",
|
||||||
"has_videos": "Whether added assets contain videos (boolean)",
|
"has_videos": "Whether added assets contain videos (boolean)",
|
||||||
"has_photos": "Whether added assets contain photos (boolean)",
|
"has_photos": "Whether added assets contain photos (boolean)",
|
||||||
|
"has_oversized_videos": "Whether any video exceeds the target's size limit (boolean)",
|
||||||
|
"max_video_size": "Target video size limit in bytes (null if no limit)",
|
||||||
|
"max_video_size_mb": "Target video size limit in MB (null if no limit)",
|
||||||
# Immich aliases
|
# Immich aliases
|
||||||
"album_name": "Alias for collection_name",
|
"album_name": "Alias for collection_name",
|
||||||
"album_id": "Alias for collection_id",
|
"album_id": "Alias for collection_id",
|
||||||
@@ -178,6 +181,8 @@ async def get_template_variables():
|
|||||||
"city": "City name",
|
"city": "City name",
|
||||||
"state": "State/region name",
|
"state": "State/region name",
|
||||||
"country": "Country name",
|
"country": "Country name",
|
||||||
|
"file_size": "File size in bytes (null if unknown)",
|
||||||
|
"oversized": "Whether video exceeds the target's size limit (boolean, videos only)",
|
||||||
"public_url": "Per-asset public share URL (empty if no album link)",
|
"public_url": "Per-asset public share URL (empty if no album link)",
|
||||||
"url": "Public viewer URL (if shared)",
|
"url": "Public viewer URL (if shared)",
|
||||||
"download_url": "Direct download URL (if shared)",
|
"download_url": "Direct download URL (if shared)",
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
"""Sample template context for previews and test notifications."""
|
"""Sample template context for previews and test notifications.
|
||||||
|
|
||||||
|
IMPORTANT: Keep sample assets and context in sync with:
|
||||||
|
- ``notify_bridge_core.templates.context.build_template_context`` (runtime variables)
|
||||||
|
- ``notify_bridge_server.api.template_configs`` (variable docs)
|
||||||
|
- ``notify_bridge_core.templates.validator`` (runtime_vars whitelist)
|
||||||
|
|
||||||
|
When adding new template variables, update all four locations.
|
||||||
|
"""
|
||||||
|
|
||||||
# Sample asset matching what build_asset_detail() actually returns
|
# Sample asset matching what build_asset_detail() actually returns
|
||||||
_SAMPLE_ASSET = {
|
_SAMPLE_ASSET = {
|
||||||
@@ -21,6 +29,8 @@ _SAMPLE_ASSET = {
|
|||||||
"public_url": "https://immich.example.com/share/abc123/photos/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
"public_url": "https://immich.example.com/share/abc123/photos/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||||
"download_url": "https://immich.example.com/api/assets/abc123/original",
|
"download_url": "https://immich.example.com/api/assets/abc123/original",
|
||||||
"photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
|
"photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
|
||||||
|
"file_size": 3_500_000, # 3.5 MB
|
||||||
|
"oversized": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
_SAMPLE_VIDEO_ASSET = {
|
_SAMPLE_VIDEO_ASSET = {
|
||||||
@@ -33,6 +43,8 @@ _SAMPLE_VIDEO_ASSET = {
|
|||||||
"photo_url": None,
|
"photo_url": None,
|
||||||
"public_url": "https://immich.example.com/share/abc123/photos/d4e5f6a7-b8c9-0123-defg-456789abcdef",
|
"public_url": "https://immich.example.com/share/abc123/photos/d4e5f6a7-b8c9-0123-defg-456789abcdef",
|
||||||
"playback_url": "https://immich.example.com/api/assets/def456/video",
|
"playback_url": "https://immich.example.com/api/assets/def456/video",
|
||||||
|
"file_size": 75_000_000, # 75 MB — exceeds Telegram's 50 MB limit
|
||||||
|
"oversized": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
_SAMPLE_COLLECTION = {
|
_SAMPLE_COLLECTION = {
|
||||||
@@ -68,6 +80,9 @@ _SAMPLE_CONTEXT = {
|
|||||||
"target_type": "telegram",
|
"target_type": "telegram",
|
||||||
"has_videos": True,
|
"has_videos": True,
|
||||||
"has_photos": True,
|
"has_photos": True,
|
||||||
|
"has_oversized_videos": True,
|
||||||
|
"max_video_size": 50 * 1024 * 1024, # 50 MB in bytes
|
||||||
|
"max_video_size_mb": 50,
|
||||||
# Rename fields (always present, empty for non-rename events)
|
# Rename fields (always present, empty for non-rename events)
|
||||||
"old_name": "Old Album",
|
"old_name": "Old Album",
|
||||||
"new_name": "New Album",
|
"new_name": "New Album",
|
||||||
|
|||||||
Reference in New Issue
Block a user