refactor(frontent): move components and ts to lib folder

This commit is contained in:
2025-12-29 18:38:20 +01:00
parent 54b9e1fc78
commit abf227d15d
30 changed files with 41 additions and 40 deletions
+302
View File
@@ -0,0 +1,302 @@
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;
}
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);
}
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);
}
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);
}
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")
[ "$created" = "-" ] && created=$(stat -c '%y' -- "$f")
jq -n --arg name "$f" --arg type "$typ" --arg size "$size" --arg created "$created" \
'{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 response: FileInfo[] = json_response.stdout
.trim()
.split('\n')
.filter(Boolean)
.map((line: string) => JSON.parse(line) as FileInfo);
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) === '.') 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);
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;
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}"`;
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);
}
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);
}
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);
}
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;
}
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;
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?: 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?: 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;
}
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;
}
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();
+34
View File
@@ -0,0 +1,34 @@
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.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() {
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 screenshot_loop(display.id);
}
+196
View File
@@ -0,0 +1,196 @@
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 async function is_display_name_taken(name: string): Promise<boolean> {
const exists = await db.displays.where('name').equals(name).first();
return !!exists;
}
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
) {
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 async function remove_display(display_id: string) {
select(selected_display_ids, display_id, 'deselect');
await delete_and_deselect_unique_files_from_display(display_id);
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 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 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 async function get_display_by_id(display_id: string): Promise<Display | null> {
return (await db.displays.get(display_id)) ?? 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 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 async function get_display_groups(): Promise<DisplayGroup[]> {
return await db.display_groups.orderBy('position').toArray();
}
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 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 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]);
}
}
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");
}
+373
View File
@@ -0,0 +1,373 @@
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 [];
});
deactivate_old_thumbnail_urls();
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 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 of await db.displays.where('id').anyOf(get(selected_display_ids)).toArray()) {
await update_folder_elements_recursively(display, current_path);
}
}
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 };
}
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)
);
}
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);
}
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))
);
}
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 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 await get_recursive_changed_directory_paths(
display,
new_file_path,
tree_folder_element.contents
)) {
has_changed.add(string);
}
}
}
return has_changed;
}
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;
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();
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: 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_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();
return sort_files(existing_files_on_selected_displays_in_path);
}
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;
// Danach alphabetisch nach name (case-insensitive)
const nameCompare = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
if (nameCompare !== 0) return nameCompare;
return -1;
});
return files;
}
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> {
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);
const display = await get_display_by_id(display_id);
if (!display) continue;
await action(display.ip, file_names_on_display);
}
}
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);
}
}
}
@@ -0,0 +1,28 @@
import { writable } from "svelte/store";
export type Notification = {
id: number;
title: string;
message: string;
duration: number;
className: string;
type?: "error" | "success" | "info";
};
function createNotifications() {
const { subscribe, update } = writable<Notification[]>([]);
function push(type: "error" | "success" | "info", title: string, message: string = "", className: string = "") {
const id = Date.now();
const duration = type === "error" ? 16000 : 4000;
update((n) => [...n, { id, title, message, duration, className, type }]);
setTimeout(() => {
update((n) => n.filter((x) => x.id !== id));
}, duration);
}
const remove = (id: number) => update(n => n.filter(x => x.id !== id));
return { subscribe, push, remove };
}
export const notifications = createNotifications();
+26
View File
@@ -0,0 +1,26 @@
import { writable, type Writable } from 'svelte/store';
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,
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);
}
+43
View File
@@ -0,0 +1,43 @@
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: 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;
folder_element.thumbnail = thumbnail_blob;
await db.files.put(folder_element); // save
}
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 [];
});
}
@@ -0,0 +1,72 @@
import { writable, type Writable } from "svelte/store";
export const dnd_flip_duration_ms = 300;
const heights_options: Record<string, { min: number; max: number; step: number }> = {
display: {
min: 15,
max: 40,
step: 5,
},
file: {
min: 10,
max: 20,
step: 5,
}
}
export const current_height: Writable<Record<string, number>> = writable<Record<string, number>>({
display: 25,
file: 10,
});
export const is_group_drag: Writable<boolean> = writable<boolean>(false);
export const is_display_drag: Writable<boolean> = writable<boolean>(false);
export const pinned_display_id: Writable<string | null> = writable<string | null>(null);
export function change_height(key: 'display' | 'file', factor: number) {
current_height.update((height) => {
height[key] = next_height_step_size(key, height, factor) || height[key];
return height;
});
}
export function next_height_step_size(key: 'display' | 'file', current_height_array: Record<string, number>, factor: number): number {
const new_size = current_height_array[key] + (factor * heights_options[key].step);
if (new_size > heights_options[key].max || new_size < heights_options[key].min) {
return 0;
} else {
return new_size;
}
}
export function get_selectable_color_classes(
selected: boolean,
returning_classes: { bg?: boolean; hover?: boolean; active?: boolean; text?: boolean } = {},
base_bg_distance: number = 0,
shifted_distance: number = 0
) {
let base_bg = selected ? 'bg-stone-400' : 'bg-stone-600';
const base_text = selected ? 'text-stone-950' : 'text-stone-200';
base_bg = get_shifted_color(base_bg, base_bg_distance);
const { bg = false, hover = false, active = false, text = false } = returning_classes;
const out: string[] = [];
if (bg) out.push(base_bg);
if (hover) out.push('hover:' + get_shifted_color(base_bg, 100 + shifted_distance));
if (active) out.push('active:' + get_shifted_color(base_bg, 150 + shifted_distance));
if (text) out.push(base_text);
return out.join(' ');
}
export function get_shifted_color(base_color: string, distance: number): string {
return base_color.replace(/(\d+)(?=(?:\/\d+)?$)/, (m: string) => String(+Number(m) - distance));
}
+97
View File
@@ -0,0 +1,97 @@
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?: Record<string, unknown>;
};
export type ShellCommandResponse = {
stdout: string;
stderr: string;
exitCode: number;
};
export type SupportedFileType = {
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
};
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;
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 | 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;
};
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;
}
+83
View File
@@ -0,0 +1,83 @@
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
>;
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();
}
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';
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 size_string = `${value.toFixed(toFixed !== null ? toFixed : Math.max(0, 2 - Math.floor(Math.log10(value))))} ${sizes[i]}`;
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);
// 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);
// 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
}
export function is_valid_name(input: string): boolean {
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 '???';
}
}