refactor(frontend): use db for files & displays

Co-Authored-By: E44 <129310925+programmer-44@users.noreply.github.com>
This commit is contained in:
2025-12-29 16:28:26 +01:00
parent 880725a105
commit c6b0c1cf31
30 changed files with 1258 additions and 1089 deletions
-18
View File
@@ -1506,7 +1506,6 @@
"integrity": "sha512-zN2yzBc2dIES2BSzLhNP2weYhwB77kgM/oAktICZVmmljyEmPZrlUwr14jjdK9/eDu7WdAuf6gTdYIJLTcN3Fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
@@ -1546,7 +1545,6 @@
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
"debug": "^4.4.1",
@@ -1903,7 +1901,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.11.0.tgz",
"integrity": "sha512-kmS7ZVpHm1EMnW1Wmft9H5ZLM7E0G0NGBx+aGEHGDcNxZBXD2ZUa76CuWjIhOGpwsPbELp684ZdpF2JWoNi4Dg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -2105,7 +2102,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.11.0.tgz",
"integrity": "sha512-4Ane7VCVZ+GFOQNuy2nMP+SoWH7EemC3geTTqvgHm1H0tbSosxLJAVaZ9dF06F35RJmYCm+jLJUhRVd156eCRQ==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -2238,7 +2234,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.11.0.tgz",
"integrity": "sha512-g43beA73ZMLezez1st9LEwYrRHZ0FLzlsSlOZKk7sdmtHLmuqWHf4oyb0XAHol1HZIdGv104rYaGNgmQXr1ecQ==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -2253,7 +2248,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.11.0.tgz",
"integrity": "sha512-plCQDLCZIOc92cizB8NNhBRN0szvYR3cx9i5IXo6v9Xsgcun8KHNcJkesc2AyeqdIs0BtOJZaqQ9adHThz8UDw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
@@ -2403,7 +2397,6 @@
"integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.0",
"@typescript-eslint/types": "8.46.0",
@@ -2621,7 +2614,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3018,7 +3010,6 @@
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4198,7 +4189,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4226,7 +4216,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -4346,7 +4335,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -4480,7 +4468,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@@ -4510,7 +4497,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@@ -4559,7 +4545,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz",
"integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@@ -5222,7 +5207,6 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.39.11.tgz",
"integrity": "sha512-8MxWVm2+3YwrFbPaxOlT1bbMi6OTenrAgks6soZfiaS8Fptk4EVyRIFhJc3RpO264EeSNwgjWAdki0ufg4zkGw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -5472,7 +5456,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5541,7 +5524,6 @@
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -16,7 +16,7 @@
menu_class = 'right-0',
div_class = '',
children
} = $props<{
}: {
className?: string;
bg?: string;
hover_bg?: string;
@@ -28,7 +28,7 @@
menu_class?: string;
div_class?: string;
children?: any;
}>();
} = $props();
let menu_shown = $state(false);
let button_element: HTMLButtonElement;
@@ -166,8 +166,8 @@
? 'text-stone-500 cursor-not-allowed'
: 'hover:bg-white/35 active:bg-white/60 cursor-pointer ' +
option.class} rounded-lg p-2 transition-colors duration-200 select-none flex flex-row gap-2 items-center"
onclick={(e) => {
if (option.on_select) option.on_select();
onclick={async (e) => {
if (option.on_select) await option.on_select();
close_menu();
}}
>
@@ -15,7 +15,7 @@
import PopUp from './PopUp.svelte';
import type { PopupContent } from '../ts/types';
import KeyInput from './KeyInput.svelte';
import { send_keyboard_input, show_blackscreen, show_html } from '../ts/api_handler';
import { send_keyboard_input, show_blackscreen } from '../ts/api_handler';
import { run_on_all_selected_displays } from '../ts/stores/displays';
import { selected_display_ids } from '../ts/stores/select';
import { onMount } from 'svelte';
@@ -81,16 +81,16 @@
title="Vorherige Folie (Pfeil nach Links)"
className="px-9"
disabled={$selected_display_ids.length === 0}
click_function={() => {
run_on_all_selected_displays(send_keyboard_input, true, 'VK_LEFT');
click_function={async () => {
await run_on_all_selected_displays(send_keyboard_input, true, 'VK_LEFT');
}}><ArrowBigLeft /></Button
>
<Button
title="Nächste Folie (Pfeil nach Rechts)"
className="px-9"
disabled={$selected_display_ids.length === 0}
click_function={() => {
run_on_all_selected_displays(send_keyboard_input, true, 'VK_RIGHT');
click_function={async () => {
await run_on_all_selected_displays(send_keyboard_input, true, 'VK_RIGHT');
}}><ArrowBigRight /></Button
>
</div>
@@ -102,8 +102,8 @@
<Button
className="px-3 flex gap-3 w-75 justify-normal"
disabled={$selected_display_ids.length === 0}
click_function={() => {
run_on_all_selected_displays(show_blackscreen, true);
click_function={async () => {
await run_on_all_selected_displays(show_blackscreen, true);
}}><Presentation />Blackout</Button
>
<div class="flex flex-row justify-normal">
@@ -2,10 +2,10 @@
import { GripHorizontal } from 'lucide-svelte';
import { dragHandle } from 'svelte-dnd-action';
let { bg, className = "" } = $props<{
let { bg, className = "" }: {
bg: string;
className?: string;
}>();
className?: string;
} = $props();
</script>
<div
@@ -10,51 +10,61 @@
import { flip } from 'svelte/animate';
import DisplayObject from './DisplayObject.svelte';
import {
add_empty_display_group,
all_displays_of_group_selected,
remove_empty_display_groups,
get_display_ids_in_group,
select_all_of_group,
set_new_display_group_data
set_new_display_order
} from '../ts/stores/displays';
import DNDGrip from './DNDGrip.svelte';
import { fade } from 'svelte/transition';
import type { DisplayGroup, MenuOption } from '../ts/types';
import { selected_display_ids } from '../ts/stores/select';
import { liveQuery } from 'dexie';
import { onMount } from 'svelte';
let { display_group, get_display_menu_options, close_pinned_display } = $props<{
display_group: DisplayGroup;
let { display_group_id, get_display_menu_options, close_pinned_display }: {
display_group_id: string;
get_display_menu_options: (display_id: string) => MenuOption[];
close_pinned_display: () => void;
}>();
} = $props
();
let all_selected = liveQuery(() =>
all_displays_of_group_selected(display_group_id, $selected_display_ids)
);
let display_ids_in_group = liveQuery(() => get_display_ids_in_group(display_group_id));
let hovering_selectable = $state(false);
function select_all_of_this_group() {
const new_value = !all_displays_of_group_selected(display_group, $selected_display_ids);
select_all_of_group(display_group, new_value);
async function select_all_of_this_group() {
const new_value = !(await all_displays_of_group_selected(
display_group_id,
$selected_display_ids
));
await select_all_of_group(display_group_id, new_value);
}
function handle_consider(e: CustomEvent) {
async function handle_consider(e: CustomEvent) {
const { items, info } = e.detail;
if (items.length !== 1 && info.trigger === TRIGGERS.DRAG_STARTED) {
$is_display_drag = true;
add_empty_display_group();
// add_empty_display_group();
}
set_new_display_group_data(display_group.id, items);
await set_new_display_order(items);
}
function handle_finalize(e: CustomEvent) {
remove_empty_display_groups();
async function handle_finalize(e: CustomEvent) {
$is_display_drag = false;
set_new_display_group_data(display_group.id, e.detail.items);
await set_new_display_order(e.detail.items);
}
</script>
<div
transition:fade={{ duration: 100 }}
class="{get_selectable_color_classes(
all_displays_of_group_selected(display_group, $selected_display_ids),
$all_selected || false,
{
bg: true,
hover: hovering_selectable,
@@ -68,7 +78,7 @@
<div
class="flex flex-col min-w-0 pl-2 py-2 gap-2 w-full"
use:dragHandleZone={{
items: display_group.data,
items: $display_ids_in_group || [],
type: 'item',
flipDurationMs: dnd_flip_duration_ms,
dropTargetStyle: { outline: 'none' }
@@ -76,7 +86,7 @@
onconsider={handle_consider}
onfinalize={handle_finalize}
>
{#each display_group.data as display (display.id)}
{#each $display_ids_in_group || [] as display (display.id)}
<!-- Each Group -->
<section
animate:flip={{ duration: $is_group_drag ? 0 : dnd_flip_duration_ms, easing: cubicOut }}
@@ -87,7 +97,7 @@
</section>
{/each}
{#if display_group.data.length === 0}
{#if ($display_ids_in_group || []).length === 0}
<div class="min-h-10 h-full w-full pl-2 py-2 flex justify-center items-center">
Hier in leere neue Gruppe ablegen
</div>
@@ -95,18 +105,18 @@
</div>
<div
class="flex items-center justify-center px-2"
onclick={(e) => select_all_of_this_group()}
onclick={select_all_of_this_group}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') select_all_of_this_group();
onkeydown={async (e) => {
if (e.key === 'Enter' || e.key === ' ') await select_all_of_this_group();
}}
onmouseenter={() => (hovering_selectable = true)}
onmouseleave={() => (hovering_selectable = false)}
>
<DNDGrip
bg={get_selectable_color_classes(
all_displays_of_group_selected(display_group, $selected_display_ids),
$all_selected || false,
{
bg: true
},
+17 -11
View File
@@ -10,30 +10,36 @@
import OnlineState from './OnlineState.svelte';
import type { Display, MenuOption } from '../ts/types';
import { is_selected, select, selected_display_ids } from '../ts/stores/select';
import { update_screenshot } from '../ts/stores/displays';
import { filter_file_selection_for_current_selected_displays } from '../ts/stores/files';
import { screenshot_loop } from '../ts/stores/displays';
import { change_file_path, current_file_path } from '../ts/stores/files';
let { display, get_display_menu_options, close_pinned_display } = $props<{
let {
display,
get_display_menu_options,
close_pinned_display
}: {
display: Display;
get_display_menu_options: (display_id: string) => MenuOption[];
close_pinned_display: () => void;
}>();
} = $props();
let hovering_unselectable = $state(false);
function onclick(e: Event) {
select(selected_display_ids, display.id);
filter_file_selection_for_current_selected_displays();
async function onclick(e: Event) {
e.stopPropagation();
select(selected_display_ids, display.id, 'toggle');
// force file view update
await change_file_path($current_file_path);
}
function on_preview_click(e: MouseEvent) {
async function on_preview_click(e: MouseEvent) {
if ($pinned_display_id === display.id) {
close_pinned_display();
} else {
$pinned_display_id = display.id;
}
update_screenshot(display.id);
await screenshot_loop(display.id);
e.stopPropagation();
}
</script>
@@ -65,8 +71,8 @@
<div class="size-[50%]">
<Pin class="size-full" />
</div>
{:else if display.preview_url}
<img src={display.preview_url} alt="preview" class="w-full object-cover bg-black" />
{:else if display.preview.url}
<img src={display.preview.url} alt="preview" class="w-full object-cover bg-black" />
{:else}
<!-- No Signal -->
<div class="size-full bg-black flex justify-center items-center">
+45 -32
View File
@@ -2,9 +2,10 @@
import { fade, scale } from 'svelte/transition';
import {
all_displays_of_group_selected,
displays,
get_display_by_id,
select_all_of_group
get_display_groups,
select_all_of_group,
set_new_display_group_order
} from '../ts/stores/displays';
import {
change_height,
@@ -27,15 +28,23 @@
import DisplayGroupObject from './DisplayGroupObject.svelte';
import { Pane, Splitpanes } from 'svelte-splitpanes';
import HighlightedText from './HighlightedText.svelte';
import { liveQuery, type Observable } from 'dexie';
let { handle_display_deletion, handle_display_editing } = $props<{
let {
handle_display_deletion,
handle_display_editing
}: {
handle_display_deletion: (display_id: string) => void;
handle_display_editing: (display_id: string) => void;
}>();
} = $props();
let displays_scroll_box: HTMLElement;
let pinned_display: Display | null = $derived(
get_display_by_id($pinned_display_id || '', $displays)
let pinned_display: Observable<Display | null> = liveQuery(() =>
get_display_by_id($pinned_display_id || '')
);
let display_groups = liveQuery(() => get_display_groups());
let all_groups_selected = liveQuery(() =>
all_selected($display_groups || [], $selected_display_ids)
);
let last_pinned_pane_size: number = 45;
@@ -66,16 +75,22 @@
];
}
function select_all(current_displays: DisplayGroup[], current_selected_display_ids: string[]) {
const new_value = !all_selected(current_displays, current_selected_display_ids);
async function select_all(
current_displays: DisplayGroup[],
current_selected_display_ids: string[]
) {
const new_value = !(await all_selected(current_displays, current_selected_display_ids));
for (const display_group of current_displays) {
select_all_of_group(display_group, new_value);
await select_all_of_group(display_group.id, new_value);
}
}
function all_selected(current_displays: DisplayGroup[], current_selected_display_ids: string[]) {
async function all_selected(
current_displays: DisplayGroup[],
current_selected_display_ids: string[]
) {
for (const display_group of current_displays) {
if (!all_displays_of_group_selected(display_group, current_selected_display_ids)) {
if (!(await all_displays_of_group_selected(display_group.id, current_selected_display_ids))) {
return false;
}
}
@@ -121,16 +136,16 @@
>
<span
class="text-xl font-bold pl-2 content-center truncate min-w-0"
title={pinned_display?.name}
title={$pinned_display?.name || '...'}
>
{pinned_display?.name}
{$pinned_display?.name || '...'}
</span>
<div class="flex flex-row gap-1">
<div class="flex flex-row items-center mr-1">
<span class="text-stone-400"> Aktueller Status: </span>
<OnlineState
selected={false}
status={pinned_display?.status ?? null}
status={$pinned_display?.status ?? null}
className="flex items-center px-2"
/>
</div>
@@ -159,9 +174,9 @@
<div
class="h-full bg-stone-800 rounded-b-2xl overflow-hidden flex justify-center items-center"
>
{#if pinned_display?.preview_url}
{#if $pinned_display?.preview.url}
<img
src={pinned_display.preview_url}
src={$pinned_display.preview.url}
alt="preview"
class="max-h-full max-w-full object-cover bg-black"
/>
@@ -188,7 +203,7 @@
<div class="flex flex-row gap-1">
<button
class="min-w-40 px-4 rounded-xl cursor-pointer duration-200 transition-colors {get_selectable_color_classes(
all_selected($displays, $selected_display_ids),
$all_groups_selected || false,
{
bg: true,
hover: true,
@@ -196,13 +211,9 @@
text: true
}
)}"
onclick={() => select_all($displays, $selected_display_ids)}
onclick={async () => await select_all($display_groups || [], $selected_display_ids)}
>
<span
>{all_selected($displays, $selected_display_ids)
? 'Alle abwählen'
: 'Alle auswählen'}</span
>
<span>{$all_groups_selected || false ? 'Alle abwählen' : 'Alle auswählen'}</span>
</button>
<div class="flex flex-row">
<Button
@@ -234,30 +245,32 @@
<div
class="min-h-full p-2 flex flex-col gap-4"
use:dragHandleZone={{
items: $displays,
items: $display_groups || [],
type: 'group',
flipDurationMs: dnd_flip_duration_ms,
dropFromOthersDisabled: true,
dropTargetStyle: { outline: 'none' }
}}
onconsider={(e: CustomEvent) => {
onconsider={async (e: CustomEvent) => {
$is_group_drag = true;
$displays = e.detail.items;
await set_new_display_group_order(e.detail.items);
}}
onfinalize={(e: CustomEvent) => {
$displays = e.detail.items;
onfinalize={async (e: CustomEvent) => {
await set_new_display_group_order(e.detail.items);
$is_group_drag = false;
}}
>
{#if $displays.length === 1 && $displays[0].data.length === 0}
{#if ($display_groups || []).length === 0}
<div class="text-stone-500 px-10 py-6 leading-relaxed text-center">
Es wurden noch keine Bildschirme hinzugefügt. Klicke oben rechts auf
<HighlightedText fg="text-stone-450" className="!p-1"><Settings class="inline pb-1" /></HighlightedText>
<HighlightedText fg="text-stone-450" className="!p-1"
><Settings class="inline pb-1" /></HighlightedText
>
und
<HighlightedText fg="text-stone-450">Neuen Bildschirm hinzufügen</HighlightedText>.
</div>
{:else}
{#each $displays as display_group (display_group.id)}
{#each $display_groups || [] as display_group (display_group.id)}
<!-- Each Group -->
<section
out:scale={{ duration: dnd_flip_duration_ms, easing: cubicOut }}
@@ -265,7 +278,7 @@
class="outline-none"
>
<DisplayGroupObject
{display_group}
display_group_id={display_group.id}
{get_display_menu_options}
{close_pinned_display}
/>
+52 -55
View File
@@ -3,6 +3,7 @@
ClipboardPaste,
Download,
FolderPlus,
Minus,
Info,
Pen,
RefreshCcw,
@@ -17,30 +18,33 @@
import PathBar from './PathBar.svelte';
import { selected_display_ids, selected_file_ids } from '../ts/stores/select';
import {
all_files,
current_file_path,
get_current_folder_elements,
get_display_ids_where_file_is_missing,
get_display_ids_where_path_does_not_exist,
get_file_by_id,
get_longest_existing_path_and_needed_parts,
run_for_selected_files_on_selected_displays,
update_current_folder_on_selected_displays
update_current_folder_on_selected_displays,
get_displays_where_path_exists,
create_folder_on_all_selected_displays
} from '../ts/stores/files';
import { slide } from 'svelte/transition';
import FolderElementObject from './FolderElementObject.svelte';
import InodeElement from './InodeElement.svelte';
import PopUp from './PopUp.svelte';
import type { PopupContent } from '../ts/types';
import { get_file_primary_key, type Inode, type PopupContent } from '../ts/types';
import TextInput from './TextInput.svelte';
import { is_valid_name } from '../ts/utils';
import { displays, get_display_by_id, run_on_all_selected_displays } from '../ts/stores/displays';
import { create_folders, delete_files, rename_file } from '../ts/api_handler';
import { get } from 'svelte/store';
import { delete_files, rename_file } from '../ts/api_handler';
import HighlightedText from './HighlightedText.svelte';
import { liveQuery } from 'dexie';
let current_name: string = $state('');
let current_valid: boolean = $state(false);
let display_names_where_path_does_not_exist: string[] = $state([]);
let selected_files = liveQuery(() => get_selected_files($selected_display_ids));
let current_folder_elements = liveQuery(() =>
get_current_folder_elements($current_file_path, $selected_display_ids)
);
let popup_content: PopupContent = $state({
open: false,
snippet: null,
@@ -48,24 +52,26 @@
closable: true
});
async function get_selected_files(selected_file_ids: string[]): Promise<Inode[]> {
try {
const results = await Promise.all(selected_file_ids.map((id) => get_file_by_id(id)));
return results.filter((element) => element !== null);
} catch (e: unknown) {
console.error('Error on generating selected_files');
return [];
}
}
function popup_close_function() {
popup_content.open = false;
}
async function create_new_folder() {
for (const display_id of $selected_display_ids) {
const display = get_display_by_id(display_id, $displays);
if (!display) continue;
const path_data = get_longest_existing_path_and_needed_parts(
$current_file_path,
display_id,
$all_files
);
await create_folders(display.ip, path_data.existing, [
...path_data.needed,
current_name.trim()
]);
}
await create_folder_on_all_selected_displays(
current_name.trim(),
$current_file_path,
$selected_display_ids
);
await update_current_folder_on_selected_displays();
popup_close_function();
}
@@ -82,8 +88,8 @@
popup_close_function();
}
const show_edit_file_popup = () => {
const file = get_file_by_id($selected_file_ids[0], $all_files, $current_file_path);
const show_edit_file_popup = async () => {
const file = await get_file_by_id($selected_file_ids[0]);
if (!file) return;
const is_folder = file.type === 'inode/directory';
const extension = is_folder ? '' : '.' + file.name.split('.').at(-1) || '';
@@ -99,9 +105,12 @@
};
};
const show_new_folder_popup = () => {
const show_new_folder_popup = async () => {
current_name = '';
current_valid = false;
display_names_where_path_does_not_exist = (
await get_displays_where_path_exists($current_file_path, $selected_display_ids, true)
).map((display) => display.name);
popup_content = {
open: true,
snippet: new_folder_popup,
@@ -123,22 +132,18 @@
</script>
{#snippet new_folder_popup()}
{#if get_display_ids_where_path_does_not_exist($current_file_path, $selected_display_ids, $all_files).length > 0}
{#if display_names_where_path_does_not_exist.length > 0}
<span class="leading-relaxed"
>Der aktuelle Pfad <HighlightedText
>{$current_file_path.slice(0, $current_file_path.length - 1)}</HighlightedText
> existiert nicht auf {get_display_ids_where_path_does_not_exist(
$current_file_path,
$selected_display_ids,
$all_files
).length === 1
> existiert nicht auf {display_names_where_path_does_not_exist.length === 1
? 'dem Bildschirm'
: 'den Bildschirmen'}
{#each get_display_ids_where_path_does_not_exist($current_file_path, $selected_display_ids, $all_files) as display_id, i}
{#each display_names_where_path_does_not_exist as display_name, i}
{#if i !== 0}
,
{/if}
<HighlightedText>{get_display_by_id(display_id, $displays)?.name}</HighlightedText>
<HighlightedText>{display_name}</HighlightedText>
{/each}. Mit der Erstellung dieses Ordners wird der Pfad automatisch mit leeren Ordnern bis
zum aktuellen Pfad aufgefüllt.
</span>
@@ -148,14 +153,14 @@
bind:current_value={current_name}
bind:current_valid
title="Ordnername"
is_valid_function={(input: string) => {
is_valid_function={async (input: string) => {
if (input.startsWith('.')) return [false, 'Name darf nicht mit . beginnen'];
const trimmed_input = input.trim();
if (trimmed_input.length === 0 || trimmed_input.length > 50)
return [false, 'Ungültige Länge'];
if (!is_valid_name(trimmed_input)) return [false, 'Name enthält ungültige Zeichen'];
if (
get_current_folder_elements($all_files, $current_file_path, $selected_display_ids).some(
(await get_current_folder_elements($current_file_path, $selected_display_ids)).some(
(e) => e.name === trimmed_input
)
)
@@ -178,30 +183,28 @@
bind:current_value={current_name}
bind:current_valid
title="Neuer {extension === '' ? 'Ordner' : 'Datei'}name"
is_valid_function={(input: string) => {
is_valid_function={async (input: string) => {
if (input.startsWith('.')) return [false, 'Name darf nicht mit . beginnen'];
const trimmed_input = input.trim() + extension;
if (trimmed_input.length === 0 || trimmed_input.length > 50)
return [false, 'Ungültige Länge'];
if (!is_valid_name(trimmed_input)) return [false, 'Name enthält ungültige Zeichen'];
if (
get_current_folder_elements($all_files, $current_file_path, $selected_display_ids).some(
(e) =>
e.name === trimmed_input &&
e.id !== get_file_by_id($selected_file_ids[0], $all_files, $current_file_path)?.id
(await get_current_folder_elements($current_file_path, $selected_display_ids)).some(
async (e) => e.name === trimmed_input && get_file_primary_key(e) !== $selected_file_ids[0]
)
)
return [false, 'Name bereits verwendet'];
return [true, 'Gültiger Name'];
}}
enter_mode="submit"
enter_function={() => edit_file_name(current_name.trim() + extension)}
enter_function={async () => await edit_file_name(current_name.trim() + extension)}
{extension}
/>
<div class="flex flex-row justify-end gap-2">
<Button
className="px-4 font-bold"
click_function={() => edit_file_name(current_name.trim() + extension)}
click_function={async () => await edit_file_name(current_name.trim() + extension)}
disabled={!current_valid}>{extension === '' ? 'Ordner' : 'Datei'} umbenennen</Button
>
</div>
@@ -213,10 +216,8 @@
>{`${$selected_file_ids.length === 1 ? 'Folgendes Objekt' : `Folgende ${$selected_file_ids.length} Objekte`} löschen? (Wiederherstellung nicht möglich)`}</span
>
<div class="flex flex-col gap-2 overflow-auto h-full min-h-0 grow-0">
{#each $selected_file_ids
.map((file_id) => get_file_by_id(file_id, $all_files, $current_file_path))
.filter((element) => element !== null) as file}
<FolderElementObject {file} not_interactable />
{#each $selected_files || [] as file}
<InodeElement {file} not_interactable />
{/each}
</div>
</div>
@@ -239,10 +240,6 @@
</div>
{/snippet}
{#snippet clipboard_hover_snippet()}
<div></div>
{/snippet}
<div class="bg-stone-800 h-full rounded-2xl grid grid-rows-[2.5rem_1fr] min-h-0">
<div class="bg-stone-700 flex justify-between w-full p-1 rounded-t-2xl min-w-0 gap-2">
<span class="text-xl font-bold pl-2 content-center truncate min-w-0">
@@ -312,7 +309,7 @@
>
<Button
title="Ausgewählte Datei(en) einfügen"
className="!p-0 flex relative"
className="px-3 flex"
disabled={$selected_display_ids.length === 0}
>
<ClipboardPaste />
@@ -342,12 +339,12 @@
Es sind keine Bildschirme ausgewählt.
</span>
{:else}
{#each get_current_folder_elements($all_files, $current_file_path, $selected_display_ids) as folder_element (folder_element.id)}
{#each $current_folder_elements || [] as folder_element (get_file_primary_key(folder_element))}
<section in:slide={{ duration: 100 }} class="outline-none">
<FolderElementObject file={folder_element} />
<InodeElement file={folder_element} />
</section>
{/each}
{#if get_current_folder_elements($all_files, $current_file_path, $selected_display_ids).length === 0}
{#if ($current_folder_elements || []).length === 0}
<span class="text-stone-450 px-10 py-6 leading-relaxed text-center max-w-full">
Es existieren keine Dateien auf {$selected_display_ids.length === 1
? 'dem ausgewähltem Bildchirm'
@@ -4,9 +4,16 @@
bg = 'bg-stone-750',
fg = 'text-stone-200',
className = ''
} = $props<{ children: any; bg?: string; fg?: string; className?: string }>();
}: {
children: any;
bg?: string;
fg?: string;
className?: string;
} = $props();
</script>
<div class="{bg} {fg} {className} rounded-lg py-0.5 px-1 inline-block truncate min-w-0 max-w-full align-middle my-[1.5px]">
<div
class="{bg} {fg} {className} rounded-lg py-0.5 px-1 inline-block truncate min-w-0 max-w-full align-middle my-[1.5px]"
>
{@render children()}
</div>
@@ -1,24 +1,12 @@
<script lang="ts">
import {
ArrowRight,
Ban,
FileIcon,
Folder,
Play,
RefreshCcwDot,
TriangleAlert
} from 'lucide-svelte';
import { ArrowRight, Ban, FileIcon, Folder, Play } from 'lucide-svelte';
import {
current_height,
get_selectable_color_classes,
get_shifted_color
} from '../ts/stores/ui_behavior';
import Button from './Button.svelte';
import {
supported_file_type_icon,
type FolderElement,
type SupportedFileType
} from '../ts/types';
import { supported_file_type_icon, type Inode, get_file_primary_key } from '../ts/types';
import {
is_selected,
@@ -27,39 +15,23 @@
selected_file_ids
} from '../ts/stores/select';
import {
all_files,
change_file_path,
current_file_path,
get_display_ids_where_file_is_missing
get_missing_colliding_display_ids
} from '../ts/stores/files';
import RefreshPlay from './RefreshPlay.svelte';
import { get_file_size_display_string, get_file_type } from '../ts/utils';
import { open_file } from '../ts/api_handler';
import {
displays,
get_display_by_id,
run_on_all_selected_displays,
update_screenshot
} from '../ts/stores/displays';
import { run_on_all_selected_displays } from '../ts/stores/displays';
import { get_thumbnail_url } from '../ts/stores/thumbnails';
import { db } from '../ts/indexdb/file_thumbnails.db';
import { onDestroy, onMount } from 'svelte';
import { liveQuery } from 'dexie';
let { file, not_interactable = false } = $props<{
file: FolderElement;
not_interactable?: boolean;
}>();
let { file, not_interactable = false }: { file: Inode; not_interactable?: boolean } = $props();
let thumbnail_url: string | null = $state(null);
// Update thumbnail_url automatically if data is available
const subscription = liveQuery(() => db.thumbnail_blobs.get(file.hash)).subscribe({
next: async () => {
thumbnail_url = await get_thumbnail_url(file.hash);
},
error: (err) => console.error('Dexie subscription error:', err)
});
onDestroy(() => subscription.unsubscribe());
let missing_colliding_displays_ids = liveQuery(() =>
get_missing_colliding_display_ids(file, $selected_display_ids)
);
let thumbnail_url = liveQuery(() => get_thumbnail_url(file));
const is_folder = file.type === 'inode/directory';
@@ -106,13 +78,13 @@
function onclick(e: Event) {
if (not_interactable) return;
select(selected_file_ids, file.id);
select(selected_file_ids, get_file_primary_key(file), 'toggle');
e.stopPropagation();
}
async function open() {
if (is_folder) {
change_file_path($current_file_path + file.name + '/');
await change_file_path($current_file_path + file.name + '/');
} else {
const path_to_file = $current_file_path + file.name;
await run_on_all_selected_displays(open_file, true, path_to_file);
@@ -158,7 +130,7 @@
>
{#if is_folder}
<ArrowRight class="size-full" strokeWidth="3" />
{:else if get_display_ids_where_file_is_missing($current_file_path, file, $selected_display_ids, $all_files)[0].length !== 0}
{:else if $missing_colliding_displays_ids && $missing_colliding_displays_ids.missing.length !== 0}
<RefreshPlay className="size-full" />
{:else if get_file_type(file) !== null}
<Play class="size-full" strokeWidth="3" />
@@ -176,7 +148,7 @@
}}
{onclick}
class="{get_selectable_color_classes(
!not_interactable && is_selected(file.id, $selected_file_ids),
!not_interactable && is_selected(get_file_primary_key(file), $selected_file_ids),
{
bg: true,
hover: !not_interactable,
@@ -191,9 +163,9 @@
<div class="aspect-square rounded-md flex justify-center items-center">
{#if is_folder}
<Folder class="size-full p-2" />
{:else if thumbnail_url}
{:else if $thumbnail_url || null}
<img
src={thumbnail_url}
src={$thumbnail_url || null}
alt="file_thumbnail"
class="object-contain size-full select-none block p-1 rounded-lg"
draggable="false"
@@ -213,7 +185,7 @@
</div>
<div
class=" p-1 flex flex-row items-center gap-1 pr-1 {get_grayed_out_text_color_strings(
is_selected(file.id, $selected_file_ids)
is_selected(get_file_primary_key(file), $selected_file_ids)
)} duration-200 transition-colors"
>
<!-- {#if get_display_ids_where_file_is_missing($current_file_path, file, $selected_display_ids, $all_files)[1].length !== 0}
@@ -251,7 +223,7 @@
</div>
<div
class="h-[70%] border {get_grayed_out_border_color_strings(
is_selected(file.id, $selected_file_ids)
is_selected(get_file_primary_key(file), $selected_file_ids)
)} duration-200 transition-colors my-1"
></div>
<div
@@ -262,7 +234,7 @@
</div>
<div
class="h-[70%] border {get_grayed_out_border_color_strings(
is_selected(file.id, $selected_file_ids)
is_selected(get_file_primary_key(file), $selected_file_ids)
)} duration-200 transition-colors"
></div>
<div
@@ -3,8 +3,7 @@
import { get_selectable_color_classes } from '../ts/stores/ui_behavior';
import key_map_json from './../../../../shared/keys.json';
import { fade } from 'svelte/transition';
import { selected_display_ids } from '../ts/stores/select';
import { displays, get_display_by_id, run_on_all_selected_displays } from '../ts/stores/displays';
import { run_on_all_selected_displays } from '../ts/stores/displays';
import { send_keyboard_input } from '../ts/api_handler';
const bg = 'bg-stone-700';
@@ -64,7 +63,11 @@
{active ? 'Erfassung aktiv' : 'Hier für Erfassung klicken'}
<div class="absolute top-full left-0 ml-1 mt-0.5 flex flex-col-reverse text-sm text-stone-400">
{#each last_keys as key (key.id)}
<span animate:flip={{ duration: 200 }} in:fade={{ duration: 200 }} out:fade={{ duration: 500 }} >{key.key}</span>
<span
animate:flip={{ duration: 200 }}
in:fade={{ duration: 200 }}
out:fade={{ duration: 500 }}>{key.key}</span
>
{/each}
</div>
</div>
@@ -1,12 +1,16 @@
<script lang="ts">
import { type DisplayStatus } from "../ts/types";
import { display_status_to_info } from "../ts/utils";
import { type DisplayStatus } from '../ts/types';
import { display_status_to_info } from '../ts/utils';
let { selected, status, className = "" } = $props<{
let {
selected,
status,
className = ''
}: {
selected: boolean;
status: DisplayStatus;
className?: string;
}>();
className?: string;
} = $props();
function get_text_color(selected: boolean, status: DisplayStatus) {
switch (status) {
+16 -11
View File
@@ -8,9 +8,11 @@
import { flip } from 'svelte/animate';
import { cubicOut } from 'svelte/easing';
let { bg = 'bg-stone-700' } = $props<{
let {
bg = 'bg-stone-700'
}: {
bg?: string;
}>();
} = $props();
let outside_container: HTMLDivElement;
let inside_container: HTMLDivElement;
@@ -79,21 +81,21 @@
out.push({
name: '  '.repeat(i) + hidden_folders[i],
class: 'truncate max-w-80',
on_select: () => {
open_path(i + 1, path);
on_select: async () => {
await open_path(i + 1, path);
}
});
}
return out;
}
function open_path(index_of_all_folders: number, path: string) {
async function open_path(index_of_all_folders: number, path: string) {
let new_path = '/';
const all_folders = get_folders(path);
for (let i = 0; i < index_of_all_folders; i++) {
new_path += all_folders[i] + '/';
}
change_file_path(new_path);
await change_file_path(new_path);
}
onMount(() => {
@@ -113,11 +115,14 @@
<Button
className="py-1 shrink-0 grow-0 w-10"
{bg}
click_function={(e) => {
open_path(0, $current_file_path);
click_function={async () => {
await open_path(0, $current_file_path);
}}
>
<House class="size-full transition-all duration-100" strokeWidth={$current_file_path === '/' ? 2.7 : 2}/>
<House
class="size-full transition-all duration-100"
strokeWidth={$current_file_path === '/' ? 2.7 : 2}
/>
</Button>
</div>
{#if cut_folders !== 0}
@@ -144,8 +149,8 @@
? 'max-w-80 font-bold'
: 'max-w-30'}"
{bg}
click_function={(e) => {
open_path(cut_folders + i + 1, $current_file_path);
click_function={async () => {
await open_path(cut_folders + i + 1, $current_file_path);
}}
>
<ChevronRight class="shrink-0 text-stone-500 h-full" />
+2 -2
View File
@@ -10,12 +10,12 @@
close_function,
className = '',
snippet_container_class = ''
} = $props<{
}: {
content: PopupContent;
close_function: () => void;
className?: string;
snippet_container_class?: string;
}>();
} = $props();
function try_to_close() {
if (!content.closable || !content.open) return;
@@ -1,5 +1,5 @@
<script lang="ts">
let {className = ''} = $props<{className?: string}>();
let { className = '' }: { className?: string } = $props();
</script>
<svg
@@ -16,28 +16,28 @@
extension = null,
enter_mode = 'none',
enter_function = null
} = $props<{
}: {
current_value: string;
current_valid: boolean;
className?: string;
bg?: string;
title: string;
placeholder?: string;
is_valid_function?: ((input: string) => [boolean, string]) | null;
is_valid_function?: ((input: string) => [boolean, string] | Promise<[boolean, string]>) | null;
focused_on_start?: boolean;
extension?: string | null;
enter_mode?: 'none' | 'focus_next' | 'submit';
enter_function?: (() => void) | null;
}>();
} = $props();
let focus_bg = get_shifted_color(bg, 100);
let focused: boolean = $state(false);
let current_info = $state('');
let input_element: HTMLInputElement;
function validate_input() {
async function validate_input() {
if (!is_valid_function) return;
[current_valid, current_info] = is_valid_function(current_value.trim());
[current_valid, current_info] = await is_valid_function(current_value.trim());
}
function get_highlighting_string(): string {
@@ -72,12 +72,12 @@
}
}
onMount(() => {
validate_input();
onMount(async () => {
await validate_input();
if (focused_on_start && input_element) input_element.focus();
selected_display_ids.subscribe(() => {
validate_input();
selected_display_ids.subscribe(async () => {
await validate_input();
});
});
</script>
@@ -114,11 +114,11 @@
};
}
function show_text() {
async function show_text() {
const html =
editor_state.editor?.getHTML() +
`<style>:root {--background-color: ${color_states.bg.value} !important;}
</style>`;
</style>`;
await run_on_all_selected_displays(show_html, true, html);
}
+1
View File
@@ -1 +1,2 @@
export const prerender = true;
export const ssr = false;
+18 -20
View File
@@ -10,7 +10,6 @@
import TextInput from '../components/TextInput.svelte';
import {
add_display,
displays,
edit_display_data,
get_display_by_id,
is_display_name_taken,
@@ -35,6 +34,7 @@
title_class: '!text-xl',
closable: true
});
let remove_display_name = $state('');
const text_inputs_valid_null_values = {
name: { valid: false, value: '' },
@@ -81,7 +81,8 @@
};
};
const show_remove_display_popup = (display_id: string) => {
const show_remove_display_popup = async (display_id: string) => {
remove_display_name = (await get_display_by_id(display_id))?.name || '?';
popup_content = {
open: true,
snippet: remove_display_popup,
@@ -93,8 +94,8 @@
};
};
const show_edit_display_popup = (display_id: string) => {
const display = get_display_by_id(display_id, $displays);
const show_edit_display_popup = async (display_id: string) => {
const display = await get_display_by_id(display_id);
if (!display) return;
// insert existing values in text_inputs_valid
for (const key of Object.keys(text_inputs_valid) as (keyof typeof text_inputs_valid)[]) {
@@ -112,17 +113,14 @@
};
};
onMount(() => {
on_start();
});
onMount(on_start);
</script>
{#snippet remove_display_popup(display_id: string)}
<div class="max-w-prose px-2">
Soll der Bildschirm <HighlightedText
>{get_display_by_id(display_id, $displays)?.name || '?'}</HighlightedText
> wirklich gelöscht werden? Dadurch wird es von diesem Controller nicht mehr erreichbar. Die Installation
auf dem Gerät bleibt bestehen. Mit dem erneuten Hinzufügen des Bildschirms wird er wieder steuerbar.
Soll der Bildschirm <HighlightedText>{remove_display_name}</HighlightedText> wirklich gelöscht werden?
Dadurch wird es von diesem Controller nicht mehr erreichbar. Die Installation auf dem Gerät bleibt
bestehen. Mit dem erneuten Hinzufügen des Bildschirms wird er wieder steuerbar.
</div>
<div class="flex flex-row justify-end gap-2">
<Button className="px-4 font-bold" click_function={popup_close_function}>Abbrechen</Button>
@@ -130,8 +128,8 @@
hover_bg="bg-red-400"
active_bg="bg-red-500"
className="px-4 flex text-red-400 hover:text-stone-100"
click_function={() => {
remove_display(display_id);
click_function={async () => {
await remove_display(display_id);
popup_close_function();
}}>Löschen</Button
>
@@ -145,13 +143,13 @@
bind:current_valid={text_inputs_valid.name.valid}
title="Anzeigename"
placeholder="z.B. Beamer vorne links"
is_valid_function={(input: string) => {
is_valid_function={async (input: string) => {
if (!!existing_display_id) {
if (input === get_display_by_id(existing_display_id, $displays)?.name)
if (input === (await get_display_by_id(existing_display_id))?.name)
return [true, 'Gültiger Name'];
}
if (input.length === 0 || input.length > 50) return [false, 'Ungültige Länge'];
if (is_display_name_taken(input)) return [false, 'Name bereits verwendet'];
if (await is_display_name_taken(input)) return [false, 'Name bereits verwendet'];
return [true, 'Gültiger Name'];
}}
enter_mode="focus_next"
@@ -199,8 +197,8 @@
: [false, 'Ungültige MAC-Adresse'];
}}
enter_mode="submit"
enter_function={() => {
finalize_add_edit_display(existing_display_id);
enter_function={async () => {
await finalize_add_edit_display(existing_display_id);
}}
/>
<div class="flex flex-row gap-2 justify-end pt-2">
@@ -212,8 +210,8 @@
disabled={!all_text_inputs_valid()}
className="{!!existing_display_id ? 'px-4' : 'pl-3 pr-4 gap-2'} font-bold"
bg="bg-stone-650"
click_function={() => {
finalize_add_edit_display(existing_display_id);
click_function={async () => {
await finalize_add_edit_display(existing_display_id);
}}
>{#if !!existing_display_id}
Speichern
+244 -187
View File
@@ -1,49 +1,58 @@
import { notifications } from "./stores/notification";
import { to_display_status, type DisplayStatus, type FolderElement, type RequestResponse, type ShellCommandResponse, type TreeElement } from "./types";
import { get_uuid } from "./utils";
import { notifications } from './stores/notification';
import {
to_display_status,
type DisplayStatus,
type Inode,
type RequestResponse,
type ShellCommandResponse,
type TreeElement
} from './types';
export async function get_screenshot(ip: string): Promise<Blob | null> {
const options = { method: 'PATCH' };
const response = await request_display(ip, '/takeScreenshot', options);
if (!response.ok || !response.blob) return null;
return response.blob;
const options = { method: 'PATCH' };
const response = await request_display(ip, '/takeScreenshot', options);
if (!response.ok || !response.blob) return null;
return response.blob;
}
export async function open_file(ip: string, path_to_file: string): Promise<void> {
const options = { method: 'PATCH', headers: { 'content-type': 'application/octet-stream' } };
await request_display(ip, `/file${path_to_file}`, options);
const options = { method: 'PATCH', headers: { 'content-type': 'application/octet-stream' } };
await request_display(ip, `/file${path_to_file}`, options);
}
export async function send_keyboard_input(ip: string, key: string): Promise<void> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
key: key,
}),
};
await request_display(ip, '/keyboardInput', options);
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
key: key
})
};
await request_display(ip, '/keyboardInput', options);
}
export async function show_html(ip: string, html: string) {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
html: html
})
};
await request_display(ip, '/showHTML', options);
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
html: html
})
};
await request_display(ip, '/showHTML', options);
}
export async function get_file_data(ip: string, path: string): Promise<FolderElement[] | null> {
interface FileInfo {
name: string;
type: string;
size: string;
created: string;
}
const command = `cd ".${path}" && find . -maxdepth 1 -mindepth 1 -print0 | while IFS= read -r -d '' f; do
export async function get_file_data(
ip: string,
path: string
): Promise<{ folder_element: Inode; date_created: Date }[] | null> {
interface FileInfo {
name: string;
type: string;
size: string;
created: string;
}
const command = `cd ".${path}" && find . -maxdepth 1 -mindepth 1 -print0 | while IFS= read -r -d '' f; do
typ=$(file -b --mime-type -- "$f")
size=$(stat -c '%s' -- "$f")
created=$(stat -c '%w' -- "$f")
@@ -52,194 +61,242 @@ export async function get_file_data(ip: string, path: string): Promise<FolderEle
'{name:$name, type:$type, size:($size|tostring), created:$created}' | tr -d '\n'
echo
done
`
const raw_response = await run_shell_command(ip, command, true);
if (!raw_response.ok || !raw_response.json) return null;
const json_response = raw_response.json as ShellCommandResponse;
if (json_response.exitCode === 0 && json_response.stdout.trim() === '') return [];
if (handle_shell_error(ip, json_response, command, true)) return null;
`;
const raw_response = await run_shell_command(ip, command, true);
if (!raw_response.ok || !raw_response.json) return null;
const json_response = raw_response.json as ShellCommandResponse;
if (json_response.exitCode === 0 && json_response.stdout.trim() === '') return [];
if (handle_shell_error(ip, json_response, command, true)) return null;
const response: FileInfo[] = json_response.stdout.trim()
.split("\n")
.filter(Boolean)
.map((line: string) => JSON.parse(line) as FileInfo);
const response: FileInfo[] = json_response.stdout
.trim()
.split('\n')
.filter(Boolean)
.map((line: string) => JSON.parse(line) as FileInfo);
const folder_element_list: FolderElement[] = [];
const folder_element_list: { folder_element: Inode; date_created: Date }[] = [];
for (const response_element of response) {
// filter hidden files (start with '.' -> './.config')
if (response_element.name.charAt(2) !== '.') {
const truncated = {
...response_element,
created: response_element.created.slice(0, 16) // truncated to YYYY-MM-DD hh-mm -> no (milli)seconds
};
const folder_element: FolderElement = {
id: get_uuid(),
hash: JSON.stringify(truncated),
name: response_element.name.slice(2), // remove "./"
type: response_element.type,
date_created: new Date(response_element.created),
size: Number(response_element.size),
};
folder_element_list.push(folder_element);
}
}
return folder_element_list;
for (const response_element of response) {
// filter hidden files (start with '.' -> './.config')
if (response_element.name.charAt(2) === '.') continue;
const folder_element: Inode = {
path: path,
name: response_element.name.slice(2), // remove "./"
type: response_element.type,
size: Number(response_element.size),
thumbnail: null
};
folder_element_list.push({ folder_element, date_created: new Date(response_element.created) });
}
return folder_element_list;
}
export async function get_file_tree_data(ip: string, path: string): Promise<TreeElement[] | null> {
const command = `cd ".${path}" && tree -Js`
const raw_response = await run_shell_command(ip, command, true);
const command = `cd ".${path}" && tree -Js`;
const raw_response = await run_shell_command(ip, command, true);
if (!raw_response.ok || !raw_response.json) return null;
const json_response = raw_response.json as ShellCommandResponse;
if (handle_shell_error(ip, json_response, command, true)) return null;
if (!raw_response.ok || !raw_response.json) return null;
const json_response = raw_response.json as ShellCommandResponse;
if (handle_shell_error(ip, json_response, command, true)) return null;
return (JSON.parse(json_response.stdout.trim()) as [TreeElement, any])[0].contents || null;
const tree_element: TreeElement | null = JSON.parse(json_response.stdout.trim())[0] || null;
return tree_element?.contents || null;
}
export async function create_folders(ip: string, path: string, folder_names: string[]): Promise<void> {
let command = `cd ".${path}"`;
export async function create_folders(
ip: string,
path: string,
folder_names: string[]
): Promise<void> {
let command = `cd ".${path}"`;
for (const part of folder_names) {
command += ` && mkdir "${part}" && cd "${part}/"`;
}
for (const part of folder_names) {
command += ` && mkdir "${part}" && cd "${part}/"`;
}
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true);
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true);
}
export async function rename_file(ip: string, path: string, old_file_name: string, new_file_name: string): Promise<void> {
const command: string = `cd ".${path}" && mv "${old_file_name}" "${new_file_name}"`;
export async function rename_file(
ip: string,
path: string,
old_file_name: string,
new_file_name: string
): Promise<void> {
const command: string = `cd ".${path}" && mv "${old_file_name}" "${new_file_name}"`;
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true);
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true);
}
export async function delete_files(ip: string, current_path: string, file_names: string[]): Promise<void> {
let command: string = `cd ".${current_path}"`;
for (const file_name of file_names) {
command += ` && rm -r "${file_name}"`;
}
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true);
export async function delete_files(
ip: string,
current_path: string,
file_names: string[]
): Promise<void> {
let command: string = `cd ".${current_path}"`;
for (const file_name of file_names) {
command += ` && rm -r "${file_name}"`;
}
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true);
}
export async function show_blackscreen(ip: string): Promise<void> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
html: ``
})
};
await request_display(ip, '/showHTML', options);
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
html: ``
})
};
await request_display(ip, '/showHTML', options);
}
export async function get_thumbnail_blob(ip: string, path_to_file: string): Promise<Blob | null> {
const raw_response = await request_display(ip, `/file/preview${path_to_file}`, { method: 'GET' }, true, [415]);
if (!raw_response.ok || !raw_response.blob) return null
return raw_response.blob;
const raw_response = await request_display(
ip,
`/file/preview${path_to_file}`,
{ method: 'GET' },
true,
[415]
);
if (!raw_response.ok || !raw_response.blob) return null;
return raw_response.blob;
}
export async function ping_ip(ip: string): Promise<DisplayStatus> {
const raw_response = await request_control(`/ping?ip=${ip}`, { method: 'GET' });
if (!raw_response.ok || !raw_response.json) return null;
return raw_response.json.status ? to_display_status(raw_response.json.status) : null;
const raw_response = await request_control(`/ping?ip=${ip}`, { method: 'GET' });
if (!raw_response.ok || !raw_response.json) return null;
const status = raw_response.json.status;
if (typeof status === 'string') {
return to_display_status(status);
}
return null;
}
async function request_display(ip: string, api_route: string, options: { method: string, headers?: Record<string, string>, body?: any }, log_in_debug: boolean = false, supress_error_handling_http_codes: number[] = []): Promise<RequestResponse> {
const url = `http://${ip}:1323/api${api_route}`;
return await request(url, options, log_in_debug, supress_error_handling_http_codes);
async function request_display(
ip: string,
api_route: string,
options: { method: string; headers?: Record<string, string>; body?: string },
log_in_debug: boolean = false,
supress_error_handling_http_codes: number[] = []
): Promise<RequestResponse> {
const url = `http://${ip}:1323/api${api_route}`;
return await request(url, options, log_in_debug, supress_error_handling_http_codes);
}
async function request_control(api_route: string, options: { method: string, headers?: Record<string, string>, body?: any }): Promise<RequestResponse> {
const url = `${window.location.origin}/api${api_route}`;
return await request(url, options);
async function request_control(
api_route: string,
options: { method: string; headers?: Record<string, string>; body?: string }
): Promise<RequestResponse> {
const url = `${window.location.origin}/api${api_route}`;
return await request(url, options);
}
async function request(
url: string,
options: { method: string; headers?: Record<string, string>; body?: string },
log_in_debug: boolean = false,
supress_error_handling_http_codes: number[] = []
): Promise<RequestResponse> {
try {
const cache_buster = `${url.includes('?') ? '&' : '?'}=${Date.now()}`;
if (log_in_debug) {
console.debug(url + cache_buster, options.body);
} else {
console.log(url + cache_buster, options.body);
}
const response = await fetch(url + cache_buster, options);
if (response.ok || supress_error_handling_http_codes.includes(response.status)) {
const contentType = response.headers.get('content-type') || '';
let request_response: RequestResponse;
if (!contentType.includes('application/json')) {
const blob: Blob = await response.blob();
request_response = { ok: response.ok, http_code: response.status, blob: blob };
} else {
const json: Record<string, unknown> = await response.json();
request_response = { ok: response.ok, http_code: response.status, json: json };
}
if (log_in_debug) {
console.debug(request_response);
} else {
console.log(request_response);
}
return request_response;
}
async function request(url: string, options: { method: string, headers?: Record<string, string>, body?: any }, log_in_debug: boolean = false, supress_error_handling_http_codes: number[] = []): Promise<RequestResponse> {
try {
const cache_buster = `${url.includes('?') ? '&' : '?'}=${Date.now()}`;
if (log_in_debug) {
console.debug(url + cache_buster, options.body);
} else {
console.log(url + cache_buster, options.body);
}
const response = await fetch(url + cache_buster, options);
if (response.ok || supress_error_handling_http_codes.includes(response.status)) {
const contentType = response.headers.get("content-type") || "";
let request_response: RequestResponse;
if (!contentType.includes("application/json")) {
const blob: Blob = await response.blob();
request_response = { ok: response.ok, http_code: response.status, blob: blob };
} else {
const json: Object = await response.json();
request_response = { ok: response.ok, http_code: response.status, json: json };
}
if (log_in_debug) {
console.debug(request_response);
} else {
console.log(request_response);
}
return request_response;
}
let error_description: string;
try {
const json: { description: string } = await response.json();
error_description = json.description;
} catch (error_on_json_parsing: any) {
error_description = `unknown error: ${error_on_json_parsing}`;
}
console.error(url, error_description);
notifications.push("error", `Fehler ${response.status} bei API-Anfrage`, `${url}\nFehler: ${error_description}`);
} catch (error: any) {
if (error instanceof TypeError && /fetch|NetworkError/i.test(error.message)) {
console.log("Request failed - Is the targeted device online?")
} else {
console.error(url, error);
notifications.push("error", `Fataler Fehler bei API-Anfrage`, `${url}\nFehler: ${error}`);
}
}
return { ok: false };
let error_description: string;
try {
const json: { description: string } = await response.json();
error_description = json.description;
} catch (error_on_json_parsing: unknown) {
error_description = `unknown error: ${error_on_json_parsing}`;
}
console.error(url, error_description);
notifications.push(
'error',
`Fehler ${response.status} bei API-Anfrage`,
`${url}\nFehler: ${error_description}`
);
} catch (error: unknown) {
if (error instanceof TypeError && /fetch|NetworkError/i.test(error.message)) {
console.log('Request failed - Is the targeted device online?');
} else {
console.error(url, error);
notifications.push('error', `Fataler Fehler bei API-Anfrage`, `${url}\nFehler: ${error}`);
}
}
return { ok: false };
}
function handle_shell_error(ip: string, shell_response: ShellCommandResponse, shell_command: string, command_includs_cd: boolean): boolean {
if (shell_response.exitCode !== 0) {
if (command_includs_cd && shell_response.stderr && /bash: line \d+: cd: .+: No such file or directory/.test(shell_response.stderr)) {
console.log("current file_path does not exist on display:", ip);
return true;
}
console.error(shell_response);
notifications.push("error", `Fehler ${shell_response.exitCode} in API-Shell`, `${ip}\n${shell_command}\nFehler: ${shell_response.stderr}`);
return true;
}
if (shell_response.stdout.trim() === '') return true;
return false;
function handle_shell_error(
ip: string,
shell_response: ShellCommandResponse,
shell_command: string,
command_includs_cd: boolean
): boolean {
if (shell_response.exitCode !== 0) {
if (
command_includs_cd &&
shell_response.stderr &&
/bash: line \d+: cd: .+: No such file or directory/.test(shell_response.stderr)
) {
console.log('current file_path does not exist on display:', ip);
return true;
}
console.error(shell_response);
notifications.push(
'error',
`Fehler ${shell_response.exitCode} in API-Shell`,
`${ip}\n${shell_command}\nFehler: ${shell_response.stderr}`
);
return true;
}
if (shell_response.stdout.trim() === '') return true;
return false;
}
async function run_shell_command(ip: string, command: string, log_in_debug: boolean = false): Promise<RequestResponse> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
command: command
})
};
return await request_display(ip, '/shellCommand', options, log_in_debug);
}
async function run_shell_command(
ip: string,
command: string,
log_in_debug: boolean = false
): Promise<RequestResponse> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
command: command
})
};
return await request_display(ip, '/shellCommand', options, log_in_debug);
}
+56
View File
@@ -0,0 +1,56 @@
import Dexie, { type Table } from 'dexie';
import type { Display, DisplayGroup, Inode } from './types';
export interface FileOnDisplay {
display_id: string;
file_primary_key: string; // JSON.stringify([string, string, number, string])
date_created: Date;
is_loading: boolean;
percentage: number;
}
export class FileDatabase extends Dexie {
files!: Table<Inode, [string, string, number, string]>;
files_on_display!: Table<FileOnDisplay, [string, string]>;
displays!: Table<Display, string>;
display_groups!: Table<DisplayGroup, string>;
constructor() {
super('FileDatabase');
this.version(1).stores({
files: `
[path+name+size+type],
path,
name,
size,
type,
thumbnail
`,
files_on_display: `
[display_id+file_primary_key],
display_id,
file_primary_key,
date_created,
is_loading,
percentage
`,
displays: `
id,
ip,
mac,
position,
preview,
group_id,
name,
status
`,
display_groups: `
id,
position
`
});
}
}
export const db = new FileDatabase();
@@ -1,20 +0,0 @@
import Dexie, { type EntityTable } from "dexie";
export interface ThumbnailBlobDBEntry {
hash: string;
blob: Blob;
}
export class FileDatabase extends Dexie {
thumbnail_blobs!: EntityTable<ThumbnailBlobDBEntry, "hash">; // (Type, PrimaryKey)
constructor() {
super("FileDatabase");
this.version(1).stores({
thumbnail_blobs: "++hash, blob" // primary key
});
}
}
export const db = new FileDatabase();
Regular → Executable
+24 -24
View File
@@ -1,34 +1,34 @@
import { get } from "svelte/store";
import { displays, run_on_all_selected_displays, update_displays_with_map, update_screenshot } from "./stores/displays"
import { ping_ip } from "./api_handler";
import type { Display } from "./types";
import { change_file_path, update_folder_elements_recursively } from "./stores/files";
import { db } from "./indexdb/file_thumbnails.db";
import { screenshot_loop } from './stores/displays';
import { ping_ip } from './api_handler';
import type { Display } from './types';
import { update_folder_elements_recursively } from './stores/files';
import { db } from './files_display.db';
const update_display_status_interval_seconds = 20;
export async function on_start() {
await db.thumbnail_blobs.clear();
await update_all_display_status();
await setInterval(update_all_display_status, update_display_status_interval_seconds * 1000);
await db.files.clear();
await db.files_on_display.clear();
await update_all_display_status();
await setInterval(update_all_display_status, update_display_status_interval_seconds * 1000);
}
async function update_all_display_status() {
await update_displays_with_map(async (display: Display) => {
const new_status = await ping_ip(display.ip);
if (new_status === null && display.status !== null) return display;
if (new_status === "app_online" && display.status !== "app_online") {
await on_display_start(display);
}
return { ...display, status: new_status, };
});
console.log("Display Status updated")
const all_displays: Display[] = await db.displays.toArray();
for (const display of all_displays) {
const new_status = await ping_ip(display.ip);
if (new_status === null && display.status !== null) continue;
if (new_status === 'app_online' && display.status !== 'app_online') {
on_display_start(display);
}
if (new_status !== display.status) {
display.status = new_status;
await db.displays.put(display); // save
}
}
}
async function on_display_start(display: Display) {
await update_folder_elements_recursively(display, '/');
await update_screenshot(display.id);
}
await update_folder_elements_recursively(display, '/');
await screenshot_loop(display.id);
}
+168 -163
View File
@@ -1,191 +1,196 @@
import { get, writable, type Writable } from "svelte/store";
import type { Display, DisplayGroup, DisplayStatus } from "../types";
import { is_selected, select, selected_display_ids } from "./select";
import { get_uuid, image_content_hash } from "../utils";
import { get_screenshot } from "../api_handler";
import { filter_file_selection_for_current_selected_displays } from "./files";
import { get } from 'svelte/store';
import type { Display, DisplayGroup, DisplayStatus } from '../types';
import { is_selected, select, selected_display_ids } from './select';
import { get_uuid, image_content_hash } from '../utils';
import { get_screenshot } from '../api_handler';
import { delete_and_deselect_unique_files_from_display } from './files';
import { db } from '../files_display.db';
export const displays: Writable<DisplayGroup[]> = writable<DisplayGroup[]>([{
id: get_uuid(),
data: []
}]);
export function is_display_name_taken(name: string): boolean {
const display_groups = get(displays);
return display_groups.some(group =>
group.data.some(display => display.name.trim().toLowerCase() === name.trim().toLowerCase())
);
export async function is_display_name_taken(name: string): Promise<boolean> {
const exists = await db.displays.where('name').equals(name).first();
return !!exists;
}
export function add_display(ip: string, mac: string | null, name: string, status: DisplayStatus) {
displays.update((displays: DisplayGroup[]) => {
displays[0].data.push({ id: get_uuid(), ip, preview_url: null, preview_timeout_id: null, mac, name, status });
return displays;
});
export async function add_display(
ip: string,
mac: string | null,
name: string,
status: DisplayStatus
) {
if (await is_display_name_taken(name)) return;
const new_id = get_uuid();
const group = await db.display_groups.toCollection().first();
let group_id: string;
if (group) {
group_id = group.id;
console.log('DISPLAYGROUP WURDE NICHT ERSTELLT');
} else {
group_id = get_uuid();
await db.display_groups.put({ id: group_id, position: 0 });
console.log('DISPLAYGROUP WURDE ERSTELLT');
}
const element_count_in_group = (await db.displays.where('group_id').equals(group_id).toArray())
.length;
await db.displays.put({
id: new_id,
ip,
mac,
position: element_count_in_group,
preview: { currently_updating: false, url: null },
group_id: group_id,
name,
status
});
}
export async function edit_display_data(display_id: string, ip: string, mac: string | null, name: string) {
await update_displays_with_map((display: Display) => {
if (display.id !== display_id) return display;
return { ...display, ip: ip, mac: mac, name: name };
})
export async function edit_display_data(
display_id: string,
ip: string,
mac: string | null,
name: string
) {
let display = await db.displays.get(display_id);
if (!display) return;
display = { ...display, ip: ip, mac: mac, name: name };
await db.displays.put(display); // save
}
export function remove_display(display_id: string) {
select(selected_display_ids, display_id, false);
filter_file_selection_for_current_selected_displays();
displays.update((displays: DisplayGroup[]) => {
displays = displays.map(display_group => ({
...display_group,
data: display_group.data.filter(display => display.id !== display_id)
}));
return displays;
});
export async function remove_display(display_id: string) {
select(selected_display_ids, display_id, 'deselect');
await delete_and_deselect_unique_files_from_display(display_id);
// TODO remove ID from Files usw.
const group_id = (await db.displays.get(display_id))?.group_id;
await db.displays.delete(display_id);
if (group_id && (await db.displays.where('group_id').equals(group_id).toArray()).length === 0) {
await db.display_groups.delete(group_id); // delete empty group
}
}
export function all_displays_of_group_selected(display_group: DisplayGroup, current_selected_displays: string[]) {
if (display_group.data.length === 0) return false;
for (const display of display_group.data) {
if (!is_selected(display.id, current_selected_displays)) {
return false;
}
}
return true;
export async function all_displays_of_group_selected(
display_group_id: string,
current_selected_displays: string[]
): Promise<boolean> {
const displays_of_group: Display[] = await db.displays
.where('group_id')
.equals(display_group_id)
.toArray();
if (displays_of_group.length === 0) return false;
for (const display of displays_of_group) {
if (!is_selected(display.id, current_selected_displays)) {
return false;
}
}
return true;
}
export function select_all_of_group(display_group: DisplayGroup, new_value: boolean | null = null) {
for (const display of display_group.data) {
select(selected_display_ids, display.id, new_value);
}
filter_file_selection_for_current_selected_displays();
export async function select_all_of_group(
display_group_id: string,
new_value: boolean | null = null
) {
const displays_of_group: Display[] = await db.displays
.where('group_id')
.equals(display_group_id)
.toArray();
for (const display of displays_of_group) {
let action: string;
if (new_value === true) {
action = 'select';
} else {
action = 'deselect';
}
select(selected_display_ids, display.id, action as 'toggle' | 'select' | 'deselect');
}
}
export function set_new_display_group_data(display_group_id: string, new_data: Display[]) {
displays.update((displays: DisplayGroup[]) => {
for (const display_group of displays) {
if (display_group.id === display_group_id) {
display_group.data = new_data;
}
}
return displays;
});
export async function get_display_by_id(display_id: string): Promise<Display | null> {
return (await db.displays.get(display_id)) ?? null;
}
export function get_display_by_id(display_id: string, display_group_array: DisplayGroup[]) {
const displays_array = display_group_array;
for (const display_group of displays_array) {
for (const display of display_group.data) {
if (display.id === display_id) {
return display;
}
}
}
return null;
export async function screenshot_loop(display_id: string, initial_retry_count: number = 5) {
const display = await db.displays.get(display_id);
if (!display || display.preview.currently_updating) return;
display.preview.currently_updating = true;
await db.displays.update(display.id, { preview: display.preview });
let last_hash: number | null = null;
let retry_count = initial_retry_count;
while (retry_count > 0) {
retry_count -= 1;
const new_blob = await get_screenshot(display.ip);
if (!new_blob) {
display.preview = { currently_updating: false, url: null };
await db.displays.update(display.id, { preview: display.preview });
return;
}
const new_hash = await image_content_hash(new_blob);
if (last_hash !== new_hash) {
if (display.preview.url) {
URL.revokeObjectURL(display.preview.url);
}
last_hash = new_hash;
display.preview.url = URL.createObjectURL(new_blob);
await db.displays.update(display.id, { preview: display.preview });
retry_count = initial_retry_count;
}
await new Promise((resolve) => setTimeout(resolve, 2000)); // sleep 2s
}
display.preview.currently_updating = false;
await db.displays.update(display.id, { preview: display.preview });
}
export function add_empty_display_group() {
displays.update((displays: DisplayGroup[]) => {
displays.push({
id: get_uuid(),
data: [],
});
return displays;
});
export async function run_on_all_selected_displays<T extends unknown[]>(
run_function: (ip: string, ...args: T) => void | Promise<void>,
update_screenshot_afterwards: boolean,
...args: T
) {
for (const display_id of get(selected_display_ids)) {
const display_ip = (await get_display_by_id(display_id))?.ip;
if (display_ip) {
await run_function(display_ip, ...args);
if (update_screenshot_afterwards) {
await screenshot_loop(display_id);
}
}
}
}
export function remove_empty_display_groups() {
displays.update((displays: DisplayGroup[]) => {
for (let i = displays.length - 1; i >= 0; i--) {
if (displays[i].data.length === 0) {
displays.splice(i, 1);
}
}
return displays;
});
export async function get_display_groups(): Promise<DisplayGroup[]> {
return await db.display_groups.orderBy('position').toArray();
}
export async function update_screenshot(display_id: string, check_type: "first_check" | "last_check_different" | "last_check_same" = "first_check") {
const display_ip = get_display_by_id(display_id, get(displays))?.ip;
if (!display_ip) return;
const new_blob = await get_screenshot(display_ip);
if (!new_blob) {
update_displays_with_map((display: Display) => {
if (display.id !== display_id) return display;
return { ...display, preview_url: null, preview_timeout_id: null };
})
return;
}
const display = get_display_by_id(display_id, get(displays));
let update_needed = check_type === "first_check";
if (check_type !== "first_check") {
if (display && display.preview_url) {
const old_blob = await fetch(display.preview_url).then(r => r.blob());
const new_hash = await image_content_hash(new_blob);
const old_hash = await image_content_hash(old_blob);
update_needed = old_hash !== new_hash; // if different -> update
}
}
let new_preview_timeout_id: number | null = null;
if (update_needed || check_type === "last_check_different") {
new_preview_timeout_id = setTimeout(async () => { await update_screenshot(display_id, update_needed ? "last_check_different" : "last_check_same") }, 2 * 1000);
}
if (display?.preview_timeout_id) {
clearInterval(display.preview_timeout_id);
}
if (update_needed) {
update_displays_with_map((display: Display) => {
if (display.id !== display_id) return display;
if (display.preview_url) {
URL.revokeObjectURL(display.preview_url);
}
const new_url = URL.createObjectURL(new_blob);
return { ...display, preview_url: new_url, preview_timeout_id: new_preview_timeout_id };
})
}
export async function get_display_ids_in_group(display_group_id: string): Promise<Display[]> {
const displays: Display[] = await db.displays
.where('group_id')
.equals(display_group_id)
.sortBy('position');
return displays;
}
export async function update_displays_with_map(update_function: (display: Display) => Display | Promise<Display>) {
const display_groups = get(displays);
const updated_groups = await Promise.all(
display_groups.map(async (group: DisplayGroup) => ({
...group,
data: await Promise.all(group.data.map(update_function)),
}))
);
displays.set(updated_groups);
export async function set_new_display_order(new_ordered_items: Display[]) {
for (let i = 0; i < new_ordered_items.length; i++) {
new_ordered_items[i].position = i;
await db.displays.put(new_ordered_items[i]);
}
}
export async function run_on_all_selected_displays(run_function: ((ip: string, ...args: any[]) => void | Promise<void>), update_screenshot_afterwards: boolean, ...args: any[]) {
for (const display_id of get(selected_display_ids)) {
const display_ip = get_display_by_id(display_id, get(displays))?.ip;
if (display_ip) {
await run_function(display_ip, ...args)
if (update_screenshot_afterwards) {
await update_screenshot(display_id);
}
}
}
export async function set_new_display_group_order(new_ordered_items: DisplayGroup[]) {
for (let i = 0; i < new_ordered_items.length; i++) {
new_ordered_items[i].position = i;
await db.display_groups.put(new_ordered_items[i]);
}
}
add_testing_displays();
function add_testing_displays() {
// const names = ["Vorne Rechts", "Vorne Links", "Vorne Mitte", "Fernseher Rechts", "Fernseher Bühne", "UIUIUIUIUIUIUISEHRLANGERTEXT DER IST WIRKLICH LANG, DER TEXT, so lang, dass er wirklich nirgendswo hinpasst, nichtmal da oben /\\"];
// for (const name of names) {
// add_display("127.0.0.1", "00:1A:2B:3C:4D:5E", name, "Offline");
// }
add_display("127.0.0.1", "00:1A:2B:3C:4D:5E", "PC", "host_offline");
// add_display("192.168.178.111", "D4:81:D7:C0:DF:3C", "Laptop", "host_offline");
}
setTimeout(add_testing_displays, 0);
async function add_testing_displays() {
await add_display('127.0.0.1', '00:1A:2B:3C:4D:5E', 'PC', 'host_offline');
// await add_display("192.168.178.111", "D4:81:D7:C0:DF:3C", "Laptop", "host_offline");
}
+327 -271
View File
@@ -1,317 +1,373 @@
import { get, writable, type Writable } from "svelte/store";
import type { Display, FolderElement, TreeElement } from "../types";
import { displays, get_display_by_id } from "./displays";
import { select, selected_display_ids, selected_file_ids } from "./select";
import { get_file_data, get_file_tree_data } from "../api_handler";
import { notifications } from "./notification";
import { CirclePoundSterling } from "lucide-svelte";
import { deactivate_old_thumbnail_urls, generate_thumbnail } from "./thumbnails";
export const all_files: Writable<Record<string, Record<string, FolderElement[]>>> = writable<Record<string, Record<string, FolderElement[]>>>({});
// {
// path: {
// display_id: FolderElement[]
// ...
// },
// path2: {
// display_id: FolderElement[]
// ...
// },
// ...
// }
import { get, writable, type Writable } from 'svelte/store';
import { get_file_primary_key, type Display, type Inode, type TreeElement } from '../types';
import { get_display_by_id } from './displays';
import { select, selected_display_ids, selected_file_ids } from './select';
import { create_folders, get_file_data, get_file_tree_data } from '../api_handler';
import { deactivate_old_thumbnail_urls, generate_thumbnail } from './thumbnails';
import { db, type FileOnDisplay } from '../files_display.db';
export const current_file_path: Writable<string> = writable<string>('/');
export async function change_file_path(new_path: string) {
current_file_path.update(() => {
return new_path;
});
selected_file_ids.update(() => {
return [];
})
current_file_path.update(() => {
return new_path;
});
selected_file_ids.update(() => {
return [];
});
deactivate_old_thumbnail_urls();
deactivate_old_thumbnail_urls();
for (const display_group of get(displays)) {
for (const display of display_group.data) {
const changed_paths = await get_changed_directory_paths(display, new_path);
if (!changed_paths) continue;
console.log("Update file system from", display.name, ":", changed_paths);
for (const path of changed_paths) {
update_folder_elements_recursively(display, path);
}
}
}
const displays = await db.displays.toArray();
for (const display of displays) {
const changed_paths = await get_changed_directory_paths(display, new_path);
if (!changed_paths) continue;
console.log('Update file system from', display.name, ':', changed_paths);
for (const path of changed_paths) {
await update_folder_elements_recursively(display, path);
}
}
}
export async function filter_file_selection_for_current_selected_displays() {
for (const selected_file_id of get(selected_file_ids)) {
if (!get_file_by_id(selected_file_id, get(all_files), get(current_file_path), true)) {
// file not found in selected displays
select(selected_file_ids, selected_file_id, false);
}
}
export async function delete_and_deselect_unique_files_from_display(display_id: string) {
const files_on_display = await db.files_on_display
.where('display_id')
.equals(display_id)
.toArray();
for (const file of files_on_display) {
await remove_file_from_display(display_id, file.file_primary_key);
}
await remove_all_files_without_display();
}
async function remove_file_from_display(display_id: string, file_primary_key: string) {
const found = await db.files_on_display.get([display_id, file_primary_key]);
if (!found) return;
select(selected_file_ids, file_primary_key, 'deselect');
await db.files_on_display.delete([display_id, file_primary_key]);
}
async function remove_all_files_without_display() {
const existing_file_id_strings: string[] = (await db.files_on_display
.orderBy('file_primary_key')
.uniqueKeys()) as string[];
const existing_file_id_objects: [string, string, number, string][] = existing_file_id_strings.map(
(e) => JSON.parse(e) as [string, string, number, string]
);
await db.files.where('[path+name+size+type]').noneOf(existing_file_id_objects).delete();
}
export async function update_current_folder_on_selected_displays() {
selected_file_ids.update(() => {
return [];
});
const current_path = get(current_file_path);
for (const display_id of get(selected_display_ids)) {
const display = get_display_by_id(display_id, get(displays));
if (!display) continue;
update_folder_elements_recursively(display, current_path);
}
selected_file_ids.update(() => {
return [];
});
const current_path = get(current_file_path);
for (const display of await db.displays.where('id').anyOf(get(selected_display_ids)).toArray()) {
await update_folder_elements_recursively(display, current_path);
}
}
export function get_display_ids_where_file_is_missing(path: string, file: FolderElement, selected_display_ids: string[], all_files: Record<string, Record<string, FolderElement[]>>): string[][] {
if (!all_files.hasOwnProperty(path)) return [selected_display_ids, []];
const missing: string[] = [];
const colliding: string[] = [];
Display:
for (const selected_display_id of selected_display_ids) {
if (!all_files[path].hasOwnProperty(selected_display_id)) {
missing.push(selected_display_id);
continue;
}
for (const folder_element of all_files[path][selected_display_id]) {
if (folder_element.name === file.name) {
if (folder_element.hash !== file.hash) {
colliding.push(selected_display_id);
}
continue Display;
}
}
missing.push(selected_display_id);
}
return [missing, colliding];
export async function get_missing_colliding_display_ids(
file: Inode,
selected_display_ids: string[]
): Promise<{ missing: string[]; colliding: string[] }> {
const missing: string[] = await get_display_ids_where_file_is_missing(file, selected_display_ids);
const colliding: string[] = [];
const colliding_files = await db.files
.where('[path+name]')
.equals([file.path, file.name])
.filter((e) => e.size !== file.size || e.type !== file.type)
.toArray();
for (const colliding_file of colliding_files) {
colliding.push(
...(await get_display_ids_where_file_is_missing(colliding_file, selected_display_ids))
);
}
return { missing, colliding };
}
export function get_display_ids_where_path_does_not_exist(path: string, selected_display_ids: string[], all_files: Record<string, Record<string, FolderElement[]>>): string[] {
if (!all_files.hasOwnProperty(path)) return selected_display_ids;
const out: string[] = [];
for (const selected_display_id of selected_display_ids) {
if (!all_files[path].hasOwnProperty(selected_display_id)) {
out.push(selected_display_id);
}
}
return out;
async function get_display_ids_where_file_is_missing(
file: Inode,
selected_display_ids: string[]
): Promise<string[]> {
const file_primary_key = get_file_primary_key(file);
const files_on_selected_displays = await db.files_on_display
.where('file_primary_key')
.equals(file_primary_key)
.filter((e) => selected_display_ids.includes(e.display_id))
.toArray();
return selected_display_ids.filter(
(id) => !files_on_selected_displays.some((item) => item.display_id === id)
);
}
async function get_changed_directory_paths(display: Display, file_path: string): Promise<string[] | null> {
const current_folder = await get_file_tree_data(display.ip, file_path);
const directory_strings = get_recursive_changed_directory_paths(display, file_path, current_folder, get(all_files));
if (directory_strings.size === 0) return null;
const directory_strings_array = [...directory_strings];
return directory_strings_array.filter((e) => (!directory_strings_array.some((f) => (f !== e && f.startsWith(e)))));
export async function get_displays_where_path_exists(
path: string,
selected_display_ids: string[],
invert: boolean
): Promise<Display[]> {
if (path === '/') return [];
const last_path_part =
path
.slice(0, path.length - 1)
.split('/')
.at(-1) ?? '';
const path_without_last_part = path.slice(0, path.length - (last_path_part.length + 1));
const folders_of_current_path = await db.files
.where('[path+name+type]')
.equals([path_without_last_part, last_path_part, 'inode/directory'])
.first();
if (!folders_of_current_path)
return await db.displays.where('id').anyOf(selected_display_ids).toArray();
const folder_primary_key = get_file_primary_key(folders_of_current_path);
const display_ids = selected_display_ids.filter(async (display_id) => {
const folder_exists = await db.files_on_display.get([display_id, folder_primary_key]);
if (invert) {
return !folder_exists;
} else {
return folder_exists;
}
});
return (await db.displays.bulkGet(display_ids)).filter((e) => e !== undefined);
}
function get_recursive_changed_directory_paths(display: Display, current_file_path: string, current_folder_elements: TreeElement[] | null, files: Record<string, Record<string, FolderElement[]>>): Set<string> {
const files_folder: FolderElement[] = files[current_file_path][display.id];
if ((!files_folder || files_folder.length === 0) && (!current_folder_elements || current_folder_elements.length === 0)) {
return new Set([]); // no data -> no update needed
} else if (!files_folder || !current_folder_elements || current_folder_elements.length !== files_folder.length) {
return new Set([current_file_path]); // existing data does not match new data -> update
}
let has_changed: Set<string> = new Set();
for (const tree_folder_element of current_folder_elements) {
const folder_element = files_folder.find(e => e.name === tree_folder_element.name);
if (!folder_element || (tree_folder_element.type !== "directory" && folder_element.size !== tree_folder_element.size)) {
return new Set([current_file_path]);
}
if (tree_folder_element.type === "directory" && tree_folder_element.contents) {
const new_file_path = current_file_path + tree_folder_element.name + '/';
for (const string of get_recursive_changed_directory_paths(display, new_file_path, tree_folder_element.contents, files)) {
has_changed.add(string);
}
}
}
return has_changed;
async function get_changed_directory_paths(
display: Display,
file_path: string
): Promise<string[] | null> {
const current_folder = await get_file_tree_data(display.ip, file_path);
const directory_strings = await get_recursive_changed_directory_paths(
display,
file_path,
current_folder
);
if (directory_strings.size === 0) return null;
const directory_strings_array = [...directory_strings];
return directory_strings_array.filter(
(e) => !directory_strings_array.some((f) => f !== e && f.startsWith(e))
);
}
export async function update_folder_elements_recursively(display: Display, file_path: string = '/'): Promise<number> {
const new_folder_elements = await get_file_data(display.ip, file_path);
if (new_folder_elements === null) return 0;
all_files.update((files: Record<string, Record<string, FolderElement[]>>) => {
if (!files.hasOwnProperty(file_path)) {
files[file_path] = {};
}
if (!files[file_path].hasOwnProperty(display.id)) {
files[file_path][display.id] = [];
}
async function get_recursive_changed_directory_paths(
display: Display,
current_file_path: string,
current_folder_elements: TreeElement[] | null
): Promise<Set<string>> {
const files_folder: Inode[] = await db.files.where('path').equals(current_file_path).toArray();
if (
(!files_folder || files_folder.length === 0) &&
(!current_folder_elements || current_folder_elements.length === 0)
) {
return new Set([]); // no data -> no update needed
} else if (
!files_folder ||
!current_folder_elements ||
current_folder_elements.length !== files_folder.length
) {
return new Set([current_file_path]); // existing data does not match new data -> update
}
const existing_folder_elements = files[file_path].hasOwnProperty(display.id) ? files[file_path][display.id] : [];
const has_changed: Set<string> = new Set();
for (const tree_folder_element of current_folder_elements) {
const folder_element = files_folder.find((e) => e.name === tree_folder_element.name);
if (
!folder_element ||
(tree_folder_element.type !== 'directory' && folder_element.size !== tree_folder_element.size)
) {
return new Set([current_file_path]);
}
const diff = get_folder_elements_difference(existing_folder_elements, new_folder_elements);
// Generate Thumbnails:
setTimeout(async () => {
for (const folder_element of diff.new) {
await generate_thumbnail(display.ip, file_path, folder_element);
}
}, 0)
files[file_path][display.id].push(...diff.new);
return remove_folder_elements_recursively(files, display, diff.deleted, file_path);
})
let folder_size = 0;
for (const element of new_folder_elements) {
if (element.type === 'inode/directory') {
const folder_content_size = await update_folder_elements_recursively(display, file_path + element.name + '/');
folder_size += folder_content_size;
// Update foldersize
all_files.update((files: Record<string, Record<string, FolderElement[]>>) => {
for (const current_folder_element of files[file_path][display.id]) {
if (current_folder_element.id === element.id) {
current_folder_element.size = folder_content_size;
}
}
return files;
})
} else {
folder_size += element.size;
}
}
return folder_size;
if (tree_folder_element.type === 'directory' && tree_folder_element.contents) {
const new_file_path = current_file_path + tree_folder_element.name + '/';
for (const string of await get_recursive_changed_directory_paths(
display,
new_file_path,
tree_folder_element.contents
)) {
has_changed.add(string);
}
}
}
return has_changed;
}
function remove_folder_elements_recursively(files: Record<string, Record<string, FolderElement[]>>, display: Display, folder_elements: FolderElement[], file_path: string): Record<string, Record<string, FolderElement[]>> {
if (!files.hasOwnProperty(file_path) || !files[file_path].hasOwnProperty(display.id)) {
console.error("File remove path does not exist:", files, display, folder_elements, file_path);
notifications.push("error", "Fehler beim Aktualisieren der Dateien", `File remove path does not exist: ${file_path} display_ip: ${display.ip}`);
return {};
}
for (const folder_element of folder_elements) {
files[file_path][display.id] = files[file_path][display.id].filter((f) => f.id !== folder_element.id);
export async function update_folder_elements_recursively(
display: Display,
file_path: string = '/'
): Promise<void> {
const new_folder_elements = await get_file_data(display.ip, file_path);
if (!new_folder_elements) return;
if (folder_element.type === 'inode/directory') {
const new_file_path = file_path + folder_element.name + '/';
if (!files.hasOwnProperty(new_file_path) || !files[new_file_path].hasOwnProperty(display.id)) {
console.error("File remove path does not exist (recursion):", files, display, folder_elements, file_path, new_file_path);
notifications.push("error", "Fehler beim Aktualisieren der Dateien", `File remove path does not exist (recursion): ${new_file_path} display_ip: ${display.ip}`);
return {};
}
const sub_folder = files[new_file_path][display.id];
remove_folder_elements_recursively(files, display, sub_folder, new_file_path);
}
}
const existing_file_keys_on_display_in_path: [string, string, number, string][] = (
await db.files_on_display.where('display_id').equals(display.id).toArray()
).map((e) => JSON.parse(e.file_primary_key) as [string, string, number, string]);
const existing_files_on_display_in_path: Inode[] = await db.files
.where('[path+name+size+type]')
.anyOf(existing_file_keys_on_display_in_path)
.filter((e) => e.path === file_path)
.toArray();
return files;
const diff = get_folder_elements_difference(
existing_files_on_display_in_path,
new_folder_elements
);
if (diff.new.length > 0) {
// Add new Folder-Elements
for (const new_element of diff.new) {
await db.files.put(new_element.folder_element);
const file_on_display: FileOnDisplay = {
display_id: display.id,
file_primary_key: get_file_primary_key(new_element.folder_element),
is_loading: false,
percentage: 0,
date_created: new_element.date_created
};
await db.files_on_display.put(file_on_display);
if (new_element.folder_element.type === 'inode/directory') {
await update_folder_elements_recursively(
display,
file_path + new_element.folder_element.name + '/'
);
}
}
// Generate Thumbnails:
setTimeout(async () => {
for (const new_element of diff.new) {
await generate_thumbnail(display.ip, file_path, new_element.folder_element);
}
}, 0);
}
if (diff.deleted.length > 0) {
// Remove old Folder-Elements
for (const old_element of diff.deleted) {
remove_file_from_display(display.id, get_file_primary_key(old_element));
}
await remove_all_files_without_display();
}
}
function get_folder_elements_difference(old_elements: FolderElement[], new_elements: FolderElement[]): { deleted: FolderElement[], new: FolderElement[] } {
const old_hashes = new Set(old_elements.map(e => e.hash));
const new_hashes = new Set(new_elements.map(e => e.hash));
function get_folder_elements_difference(
old_elements: Inode[],
new_elements: { folder_element: Inode; date_created: Date }[]
): { deleted: Inode[]; new: { folder_element: Inode; date_created: Date }[] } {
const old_keys = new Set(old_elements.map((e) => get_file_primary_key(e)));
const new_keys = new Set(new_elements.map((e) => get_file_primary_key(e.folder_element)));
const only_in_old = old_elements.filter(e => !new_hashes.has(e.hash));
const only_in_new = new_elements.filter(e => !old_hashes.has(e.hash));
return { deleted: only_in_old, new: only_in_new };
const only_in_old = old_elements.filter((e) => !new_keys.has(get_file_primary_key(e)));
const only_in_new = new_elements.filter(
(e) => !old_keys.has(get_file_primary_key(e.folder_element))
);
return { deleted: only_in_old, new: only_in_new };
}
export async function get_current_folder_elements(
current_file_path: string,
selected_display_ids: string[]
): Promise<Inode[]> {
const existing_file_keys_on_selected_displays: [string, string, number, string][] = (
await db.files_on_display.where('display_id').anyOf(selected_display_ids).toArray()
).map((e) => JSON.parse(e.file_primary_key) as [string, string, number, string]);
const existing_files_on_selected_displays_in_path: Inode[] = await db.files
.where('[path+name+size+type]')
.anyOf(existing_file_keys_on_selected_displays)
.filter((e) => e.path === current_file_path)
.toArray();
export function get_current_folder_elements(all_files: Record<string, Record<string, FolderElement[]>>, current_file_path: string, selected_display_ids: string[]) {
if (!all_files.hasOwnProperty(current_file_path)) return [];
const files_on_display_array = all_files[current_file_path];
const files: FolderElement[] = [];
for (const key of Object.keys(files_on_display_array)) {
if (selected_display_ids.includes(key)) {
FileOnDisplay:
for (const file_on_display of files_on_display_array[key]) {
for (const existing_file of files) {
const both_same_folder = file_on_display.type === "inode/directory" && existing_file.type === "inode/directory" && file_on_display.name === existing_file.name;
if (both_same_folder && file_on_display.size !== existing_file.size) {
existing_file.size = -1;
}
if (file_on_display.hash === existing_file.hash) {
continue FileOnDisplay;
} else if (both_same_folder) {
existing_file.date_created = null;
continue FileOnDisplay;
}
}
files.push({ ...file_on_display });
}
}
}
return sort_files(files);
return sort_files(existing_files_on_selected_displays_in_path);
}
function sort_files(files: FolderElement[]) {
files.sort((a, b) => {
const isDirA = a.type === 'inode/directory';
const isDirB = b.type === 'inode/directory';
function sort_files(files: Inode[]) {
files.sort((a, b) => {
const isDirA = a.type === 'inode/directory';
const isDirB = b.type === 'inode/directory';
// Ordner zuerst
if (isDirA && !isDirB) return -1;
if (!isDirA && isDirB) return 1;
// Ordner zuerst
if (isDirA && !isDirB) return -1;
if (!isDirA && isDirB) return 1;
// Danach alphabetisch nach name (case-insensitive)
const nameCompare = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
if (nameCompare !== 0) return nameCompare;
// Danach alphabetisch nach name (case-insensitive)
const nameCompare = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
if (nameCompare !== 0) return nameCompare;
// Wenn name gleich, absteigend nach date_created
if (!b.date_created || !a.date_created) return -1;
return b.date_created.getTime() - a.date_created.getTime();
});
return files;
return -1;
});
return files;
}
export function get_file_by_id(file_id: string, all_files: Record<string, Record<string, FolderElement[]>>, current_file_path: string, only_from_selected_displays: boolean = false): FolderElement | null {
let current_path_elements: Record<string, FolderElement[]> | undefined = all_files[current_file_path];
if (!current_path_elements) return null;
if (only_from_selected_displays) {
current_path_elements = Object.fromEntries(
Object.entries(current_path_elements).filter(([key]) => get(selected_file_ids).includes(key))
);
}
const all_folder_elements = Object.values(current_path_elements).flat();
const found = all_folder_elements.find(el => el.id === file_id);
if (!found) return null;
return found
export async function get_file_by_id(
file_primary_key: string,
only_from_selected_displays: boolean = false
): Promise<Inode | null> {
const file = (await db.files.get(JSON.parse(file_primary_key))) ?? null;
if (!file || !only_from_selected_displays) {
return file;
} else {
const exist_on_selected_display = !!(await db.files_on_display
.where('file_primary_key')
.equals(file_primary_key)
.filter((e) => get(selected_display_ids).includes(e.display_id))
.first());
return exist_on_selected_display ? file : null;
}
}
export async function run_for_selected_files_on_selected_displays(action: (ip: string, file_names: string[]) => Promise<void>): Promise<void> {
const files = get(all_files);
const file_path = get(current_file_path);
const folder_elements: FolderElement[] = get(selected_file_ids)
.map((file_id) => get_file_by_id(file_id, files, file_path))
.filter((element) => element !== null);
const folder_element_hashs = folder_elements
.map((folder_element) => folder_element.hash)
.filter((hash) => hash !== null);
export async function run_for_selected_files_on_selected_displays(
action: (ip: string, file_names: string[]) => Promise<void>
): Promise<void> {
for (const display_id of get(selected_display_ids)) {
const file_keys_on_display: [string, string, number, string][] = (
await db.files_on_display.where('display_id').equals(display_id).toArray()
).map((e) => JSON.parse(e.file_primary_key) as [string, string, number, string]);
const file_names_on_display: string[] = (
await db.files.where('[path+name+size+type]').anyOf(file_keys_on_display).toArray()
).map((e) => e.name);
for (const display_id of get(selected_display_ids)) {
if (!files[file_path].hasOwnProperty(display_id)) continue;
const files_on_display = files[file_path][display_id];
const selected_file_names_on_display: string[] = files_on_display
.filter((e) => (e.hash && folder_element_hashs.includes(e.hash)) || (e.type === 'inode/directory' && folder_elements.some(e2 => e2.name === e.name && e2.type === 'inode/directory')))
.map((folder_element) => folder_element.name);
if (selected_file_names_on_display.length === 0) continue;
const display = get_display_by_id(display_id, get(displays));
if (!display) continue;
await action(display.ip, selected_file_names_on_display);
}
const display = await get_display_by_id(display_id);
if (!display) continue;
await action(display.ip, file_names_on_display);
}
}
export function get_longest_existing_path_and_needed_parts(path: string, display_id: string, all_files: Record<string, Record<string, FolderElement[]>>): { existing: string; needed: string[] } {
const path_parts = path.slice(1, path.length - 1).split('/').filter(e => e.length !== 0); // remove front and back / and split after that, then remove empty strings
for (let i = path_parts.length; i > 0; i--) {
const current_path = '/' + [...path_parts].splice(0, i).join('/') + '/';;
if (all_files.hasOwnProperty(current_path)) {
if (all_files[current_path].hasOwnProperty(display_id)) {
return { existing: current_path, needed: [...path_parts].splice(i) };
}
}
}
return { existing: '/', needed: path_parts };
}
export async function create_folder_on_all_selected_displays(
folder_name: string,
path: string,
selected_display_ids: string[]
): Promise<void> {
const path_parts = path
.slice(1, path.length - 1)
.split('/')
.filter((e) => e.length !== 0);
let remaining_display_ids = [...selected_display_ids];
const getDisplaysForPath = async (currentPath: string): Promise<Display[]> => {
if (currentPath === '/') {
const displays = await db.displays.bulkGet(remaining_display_ids);
return displays.filter((d): d is Display => !!d);
}
return get_displays_where_path_exists(currentPath, remaining_display_ids, false);
};
for (let depth = path_parts.length; depth >= 0 && remaining_display_ids.length; depth--) {
const currentPath = depth === 0 ? '/' : `/${path_parts.slice(0, depth).join('/')}/`;
const displays = await getDisplaysForPath(currentPath);
if (!displays.length) continue;
const folders_to_create = [...path_parts.slice(depth), folder_name];
for (const display of displays) {
await create_folders(display.ip, currentPath, folders_to_create);
remaining_display_ids = remaining_display_ids.filter((id) => id !== display.id);
}
}
}
@@ -1,4 +1,4 @@
import { get, writable } from "svelte/store";
import { writable } from "svelte/store";
export type Notification = {
id: number;
+19 -16
View File
@@ -1,23 +1,26 @@
import { writable, type Writable } from "svelte/store";
import { writable, type Writable } from 'svelte/store';
export const selected_file_ids: Writable<string[]> = writable<string[]>([]);
export const selected_file_ids: Writable<string[]> = writable<string[]>([]); // JSON.stringify([string, string, number, string])
export const selected_display_ids: Writable<string[]> = writable<string[]>([]);
export function select(selected_ids: Writable<string[]>, id: string, new_value: boolean | null = null) {
selected_ids.update((all_ids: string[]) => {
if (all_ids.includes(id)) {
const index = all_ids.indexOf(id);
if (index > -1 && new_value !== true) {
all_ids.splice(index, 1);
}
} else if (new_value !== false) {
all_ids.push(id);
}
return all_ids;
});
export function select(
selected_ids: Writable<string[]>,
id: string,
action: 'toggle' | 'select' | 'deselect'
) {
selected_ids.update((all_ids: string[]) => {
if (all_ids.includes(id)) {
const index = all_ids.indexOf(id);
if (index > -1 && action !== 'select') {
all_ids.splice(index, 1);
}
} else if (action !== 'deselect') {
all_ids.push(id);
}
return all_ids;
});
}
export function is_selected(id: string, selected_ids: string[]): boolean {
return selected_ids.includes(id);
return selected_ids.includes(id);
}
+34 -29
View File
@@ -1,38 +1,43 @@
import { get, writable, type Writable } from "svelte/store";
import { get_thumbnail_blob } from "../api_handler";
import { type FolderElement } from "../types";
import { db, type ThumbnailBlobDBEntry } from "../indexdb/file_thumbnails.db";
import { get_file_type } from "../utils";
import { get, writable, type Writable } from 'svelte/store';
import { get_thumbnail_blob } from '../api_handler';
import { type Inode } from '../types';
import { db } from '../files_display.db';
import { get_file_type } from '../utils';
export const active_thumbnail_urls: Writable<string[]> = writable<string[]>([]);
export async function generate_thumbnail(display_ip: string, path: string, folder_element: FolderElement): Promise<void> {
const supported_file_type = get_file_type(folder_element);
if (!supported_file_type || !folder_element.hash) return;
const hash: string = folder_element.hash;
if (await db.thumbnail_blobs.get(hash)) return;
export async function generate_thumbnail(
display_ip: string,
path: string,
folder_element: Inode
): Promise<void> {
const supported_file_type = get_file_type(folder_element);
if (!supported_file_type) return;
const thumbnail_blob = await get_thumbnail_blob(display_ip, path + folder_element.name);
if (!thumbnail_blob) return;
await db.thumbnail_blobs.add({ hash: hash, blob: thumbnail_blob });
const thumbnail_blob = await get_thumbnail_blob(display_ip, path + folder_element.name);
if (!thumbnail_blob) return;
folder_element.thumbnail = thumbnail_blob;
await db.files.put(folder_element); // save
}
export async function get_thumbnail_url(hash: string | null): Promise<string | null> {
if (hash === null) return null;
const thumbnail_blob = await db.thumbnail_blobs.get(hash) as ThumbnailBlobDBEntry;
if (!thumbnail_blob) return null;
const new_url = URL.createObjectURL(thumbnail_blob.blob);
active_thumbnail_urls.update((current: string[]) => {
current.push(new_url);
return current;
})
return new_url;
export async function get_thumbnail_url(file: Inode): Promise<string | null> {
if (!file.thumbnail) return null;
const new_url = URL.createObjectURL(file.thumbnail);
active_thumbnail_urls.update((current: string[]) => {
current.push(new_url);
return current;
});
return new_url;
}
export function deactivate_old_thumbnail_urls() {
const current_urls = get(active_thumbnail_urls);
for (const url of current_urls) {
URL.revokeObjectURL(url);
}
active_thumbnail_urls.update(() => { return []; })
}
const current_urls = get(active_thumbnail_urls);
for (const url of current_urls) {
URL.revokeObjectURL(url);
}
active_thumbnail_urls.update(() => {
return [];
});
}
Regular → Executable
+76 -68
View File
@@ -1,89 +1,97 @@
import { FileBox, FileImage, FileText, FileVideoCamera, ImagePlay, type X } from "lucide-svelte";
import type { Snippet } from "svelte";
import { FileBox, FileImage, FileText, FileVideoCamera, ImagePlay, type X } from 'lucide-svelte';
import type { Snippet } from 'svelte';
export type RequestResponse = {
ok: boolean,
http_code?: number
blob?: Blob,
json?: any,
}
ok: boolean;
http_code?: number;
blob?: Blob;
json?: Record<string, unknown>;
};
export type ShellCommandResponse = {
stdout: string,
stderr: string,
exitCode: number,
}
stdout: string;
stderr: string;
exitCode: number;
};
export type SupportedFileType = {
display_name: string;
mime_type: string;
display_name: string;
mime_type: string;
};
export const supported_file_type_icon: Record<string, typeof X> = {
'MP4': FileVideoCamera,
'JPG': FileImage,
'PNG': FileImage,
'GIF': ImagePlay,
'PPTX': FileBox,
'ODP': FileBox,
'PDF': FileText
}
MP4: FileVideoCamera,
JPG: FileImage,
PNG: FileImage,
GIF: ImagePlay,
PPTX: FileBox,
ODP: FileBox,
PDF: FileText
};
export type FolderElement = {
id: string;
hash: string;
name: string;
type: string;
date_created: Date | null;
size: number;
export type Inode = {
path: string;
name: string;
size: number;
type: string;
date_created: Date;
thumbnail: Blob | null;
};
export function get_file_primary_key(file: Inode): string {
return JSON.stringify([file.path, file.name, file.size, file.type]);
}
export type TreeElement = {
contents?: TreeElement[];
type: "file" | "directory";
name: string;
size: number;
}
export type Display = {
id: string;
ip: string;
preview_url: string | null;
preview_timeout_id: number | null;
mac: string | null;
name: string;
status: DisplayStatus;
}
export type DisplayGroup = {
id: string;
data: Display[];
contents?: TreeElement[];
type: 'file' | 'directory';
name: string;
size: number;
};
export type Display = {
id: string;
ip: string;
mac: string | null;
position: number;
preview: PreviewObject;
group_id: string;
name: string;
status: DisplayStatus;
};
export type DisplayGroup = {
id: string;
position: number;
};
export type PreviewObject = {
currently_updating: boolean;
url: string | null;
};
export type MenuOption = {
icon?: typeof X;
name: string;
class?: string;
on_select?: () => void;
disabled?: boolean;
}
icon?: typeof X;
name: string;
class?: string;
on_select?: () => void | Promise<void>;
disabled?: boolean;
};
export type PopupContent = {
open: boolean;
snippet: Snippet<[string]> | null;
snippet_arg?: string;
title?: string;
title_class?: string;
title_icon?: typeof X | null;
window_class?: string;
closable?: boolean;
}
open: boolean;
snippet: Snippet<[string]> | null;
snippet_arg?: string;
title?: string;
title_class?: string;
title_icon?: typeof X | null;
window_class?: string;
closable?: boolean;
};
export type DisplayStatus = "host_offline" | "app_offline" | "app_online" | null;
export type DisplayStatus = 'host_offline' | 'app_offline' | 'app_online' | null;
export function to_display_status(value: string): DisplayStatus {
return ["host_offline", "app_offline", "app_online"].includes(value)
? (value as DisplayStatus)
: null;
}
return ['host_offline', 'app_offline', 'app_online'].includes(value)
? (value as DisplayStatus)
: null;
}
+57 -56
View File
@@ -1,82 +1,83 @@
import type { DisplayStatus, FolderElement, SupportedFileType } from "./types";
import type { DisplayStatus, Inode, SupportedFileType } from './types';
import supported_file_types_json from './../../../../shared/supported_file_types.json';
const supported_file_types: Record<string, SupportedFileType> = supported_file_types_json as Record<string, SupportedFileType>;
const supported_file_types: Record<string, SupportedFileType> = supported_file_types_json as Record<
string,
SupportedFileType
>;
export function get_file_type(file: FolderElement): SupportedFileType | null {
for (const key of Object.keys(supported_file_types)) {
if (file.type === supported_file_types[key].mime_type) {
return supported_file_types[key];
}
}
// Fallback:
const extension = file.name.split('.').pop();
if (extension) {
if (Object.keys(supported_file_types).includes('.' + extension)) {
return supported_file_types['.' + extension];
}
}
return null;
export function get_file_type(file: Inode): SupportedFileType | null {
for (const key of Object.keys(supported_file_types)) {
if (file.type === supported_file_types[key].mime_type) {
return supported_file_types[key];
}
}
// Fallback:
const extension = file.name.split('.').pop();
if (extension) {
if (Object.keys(supported_file_types).includes('.' + extension)) {
return supported_file_types['.' + extension];
}
}
return null;
}
export function get_uuid(): string {
return crypto.randomUUID();
return crypto.randomUUID();
}
export function get_file_size_display_string(size: number, toFixed: number | null = null): string {
if (size < 0) return toFixed === null ? "versch." : "Verschiedene Größen auf verschiedenen Bildschirmen";
if (size === 0) return "0 B";
if (size < 0)
return toFixed === null ? 'versch.' : 'Verschiedene Größen auf verschiedenen Bildschirmen';
if (size === 0) return '0 B';
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(size) / Math.log(k));
const value = size / Math.pow(k, i);
const i = Math.floor(Math.log(size) / Math.log(k));
const value = size / Math.pow(k, i);
const size_string = `${value.toFixed(toFixed !== null ? toFixed : Math.max(0, 2 - Math.floor(Math.log10(value))))} ${sizes[i]}`;
const size_string = `${value.toFixed(toFixed !== null ? toFixed : Math.max(0, 2 - Math.floor(Math.log10(value))))} ${sizes[i]}`;
return size_string.replace('.', ',');
return size_string.replace('.', ',');
}
export async function image_content_hash(blob: Blob, size = 32): Promise<number> {
// Blob → ImageBitmap (GPU-dekodiert, superschnell)
const bitmap = await createImageBitmap(blob);
// Blob → ImageBitmap (GPU-dekodiert, superschnell)
const bitmap = await createImageBitmap(blob);
// OffscreenCanvas ist sehr schnell (kein Layout nötig)
const canvas = new OffscreenCanvas(size, size);
const ctx = canvas.getContext("2d")!;
ctx.drawImage(bitmap, 0, 0, size, size);
// OffscreenCanvas ist sehr schnell (kein Layout nötig)
const canvas = new OffscreenCanvas(size, size);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0, size, size);
// Pixel-Daten holen
const { data } = ctx.getImageData(0, 0, size, size);
// Pixel-Daten holen
const { data } = ctx.getImageData(0, 0, size, size);
// Einfacher, schneller Integer-Hash (FNV-1a)
let hash = 2166136261;
for (let i = 0; i < data.length; i++) {
hash ^= data[i];
hash = Math.imul(hash, 16777619);
}
// Einfacher, schneller Integer-Hash (FNV-1a)
let hash = 2166136261;
for (let i = 0; i < data.length; i++) {
hash ^= data[i];
hash = Math.imul(hash, 16777619);
}
bitmap.close(); // GPU-Ressourcen freigeben
return hash >>> 0; // unsigned int
bitmap.close(); // GPU-Ressourcen freigeben
return hash >>> 0; // unsigned int
}
export function is_valid_name(input: string): boolean {
return /^[\p{L}\p{N}\p{M}\-_.+,()[\]{}@!§$%&=~^ ]+$/u.test(input);
return /^[\p{L}\p{N}\p{M}\-_.+,()[\]{}@!§$%&=~^ ]+$/u.test(input);
}
export function display_status_to_info(status: DisplayStatus): string {
switch (status) {
case 'app_online':
return 'Online';
case 'app_offline':
return 'Lädt';
case 'host_offline':
return 'Offline';
case null:
return '???';
}
}
switch (status) {
case 'app_online':
return 'Online';
case 'app_offline':
return 'Lädt';
case 'host_offline':
return 'Offline';
case null:
return '???';
}
}