diff --git a/control/frontend/src/lib/components/InodeElement.svelte b/control/frontend/src/lib/components/InodeElement.svelte index 1187436..a923a91 100755 --- a/control/frontend/src/lib/components/InodeElement.svelte +++ b/control/frontend/src/lib/components/InodeElement.svelte @@ -36,7 +36,8 @@ const s = $selected_file_ids; missing_colliding_displays_ids = liveQuery(() => get_missing_colliding_display_ids(file, s)); }); - let thumbnail_url = liveQuery(() => get_thumbnail_url(file)); + + let thumbnail_url = liveQuery(() => get_thumbnail_url(get_file_primary_key(file))); let date_mapping: Observable> = liveQuery(() => get_date_mapping(get_file_primary_key(file)) @@ -46,8 +47,8 @@ function get_created_info(date_mapping: Record | undefined, full_string = false) { if (!date_mapping) return ''; - const keys = Object.keys(date_mapping); + if (keys.length === 0) return ''; if (keys.length === 1) return get_formated_created_string(date_mapping[keys[0]], full_string); diff --git a/control/frontend/src/lib/ts/file_transfer_handler.ts b/control/frontend/src/lib/ts/file_transfer_handler.ts new file mode 100644 index 0000000..440a9e3 --- /dev/null +++ b/control/frontend/src/lib/ts/file_transfer_handler.ts @@ -0,0 +1,169 @@ +import { db } from './files_display.db'; +import { get_display_by_id } from './stores/displays'; +import { remove_all_files_without_display, remove_file_from_display } from './stores/files'; +import { notifications } from './stores/notification'; +import { generate_thumbnail } from './stores/thumbnails'; +import { + get_file_primary_key, + type FileOnDisplay, + type FileTransferTask, + type Inode +} from './types'; +import { get_uuid, make_valid_name } from './utils'; + +let is_processing: boolean = false; +const tasks: FileTransferTask[] = []; + +export async function add_upload( + file_list: FileList, + selected_display_ids: string[], + current_file_path: string +) { + if (file_list.length === 0) return; + + for (const file of file_list) { + const file_name = make_valid_name(file.name); + const db_file: Inode = { + path: current_file_path, + name: file_name, + size: file.size, + type: file.type, + thumbnail: null + }; + const file_primary_key = get_file_primary_key(db_file); + await db.files.put(db_file); + + const upload_task_promises = selected_display_ids.map(async (display_id) => { + const display_ip = (await get_display_by_id(display_id))?.ip; + if (!display_ip) return; + + const db_file_on_display: FileOnDisplay = { + display_id, + file_primary_key, + date_created: new Date(), + is_loading: true, + percentage: 0 + }; + await db.files_on_display.put(db_file_on_display); + + return { + id: get_uuid(), + type: 'upload' as const, + display_id, + display_ip, + path: current_file_path, + file_name: file_name, + file_primary_key, + file, + bytes_total: file.size + }; + }); + + const upload_tasks = (await Promise.all(upload_task_promises)).filter((e) => !!e); + tasks.push(...upload_tasks); + } + + await start_task_processing(); +} + +async function start_task_processing() { + if (!is_processing) { + is_processing = tasks.length !== 0; + await start_task_loop(); + } +} + +async function start_task_loop() { + while (tasks.length > 0) { + const current_task = tasks[0]; + switch (current_task.type) { + case 'upload': + await upload(current_task); + break; + case 'download': + break; + case 'sync': + break; + } + + console.log('AKTUELL IN TASKS', tasks.length); + tasks.shift(); // Remove current_task from tasks + } +} + +function file_url(ip: string, path: string, file_name: string) { + return `http://${ip}:1323/api/file${path}${encodeURIComponent(file_name)}`; +} + +async function upload(task: FileTransferTask): Promise { + if (task.type !== 'upload' || !task.file) return; + + await db.files_on_display + .where('[display_id+file_primary_key]') + .equals([task.display_id, task.file_primary_key]) + .modify({ percentage: 1 }); + + return new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', file_url(task.display_ip, task.path, task.file_name), true); + xhr.setRequestHeader('content-type', 'application/octet-stream'); + + xhr.upload.onprogress = (e) => { + const total = e.lengthComputable ? e.total : task.bytes_total; + const current_percentage = total > 0 ? Math.round((e.loaded / total) * 100) : 1; + const apply = async () => { + await db.files_on_display + .where('[display_id+file_primary_key]') + .equals([task.display_id, task.file_primary_key]) + .modify({ percentage: current_percentage }); + }; + apply(); + }; + + xhr.onerror = async (e: ProgressEvent) => { + await show_error('upload', e, task); + resolve(); + }; + + xhr.onreadystatechange = async () => { + if (xhr.readyState === 4) { + if (xhr.status == 200) { + await db.files_on_display + .where('[display_id+file_primary_key]') + .equals([task.display_id, task.file_primary_key]) + .modify({ percentage: 100, is_loading: false }); + } else { + await show_error('upload', `HTTP ${xhr.status}`, task); + } + resolve(); + } + }; + + xhr.send(task.file); + }).then(() => { + // Generate Thumbnail if not done already + setTimeout(async () => { + const inode_element: Inode | undefined = await db.files.get( + JSON.parse(task.file_primary_key) as [string, string, number, string] + ); + if (!!inode_element && inode_element.thumbnail === null) { + await generate_thumbnail(task.display_ip, task.path, inode_element); + } + }, 0); + }); +} + +async function show_error( + type: 'upload' | 'download' | 'sync', + error: ProgressEvent | string, + task: FileTransferTask +) { + notifications.push( + 'error', + `Fehler beim ${type === 'upload' ? 'Dateiupload' : type === 'download' ? 'Dateidownload' : 'Sychronisieren von Dateien'}`, + `Datei: "${task.file_name}", Display-IP: ${task.display_ip}\nFehler: ${error}` + ); + if (type === 'download') return; + await remove_file_from_display(task.display_id, task.file_primary_key); + await remove_all_files_without_display(); +} diff --git a/control/frontend/src/lib/ts/files_display.db.ts b/control/frontend/src/lib/ts/files_display.db.ts index d03d10e..de37d3f 100755 --- a/control/frontend/src/lib/ts/files_display.db.ts +++ b/control/frontend/src/lib/ts/files_display.db.ts @@ -1,13 +1,5 @@ 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; -} +import type { Display, DisplayGroup, FileOnDisplay, Inode } from './types'; export class FileDatabase extends Dexie { files!: Table; diff --git a/control/frontend/src/lib/ts/stores/files.ts b/control/frontend/src/lib/ts/stores/files.ts index 65576c0..cae2059 100755 --- a/control/frontend/src/lib/ts/stores/files.ts +++ b/control/frontend/src/lib/ts/stores/files.ts @@ -1,10 +1,16 @@ import { get, writable, type Writable } from 'svelte/store'; -import { get_file_primary_key, type Display, type Inode, type TreeElement } from '../types'; +import { + get_file_primary_key, + type Display, + type FileOnDisplay, + type Inode, + type TreeElement +} from '../types'; import { get_display_by_id } from './displays'; import { is_selected, 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'; +import { db } from '../files_display.db'; export const current_file_path: Writable = writable('/'); @@ -41,14 +47,14 @@ export async function delete_and_deselect_unique_files_from_display(display_id: await remove_all_files_without_display(); } -async function remove_file_from_display(display_id: string, file_primary_key: string) { +export 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() { +export 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[]; @@ -330,9 +336,11 @@ export async function run_for_selected_files_on_selected_displays( ).map((e) => e.file_primary_key); const selected_file_keys_on_display: [string, string, number, string][] = - file_key_strings_on_display.filter((primary_key_string) => is_selected(primary_key_string, get(selected_file_ids))).map( - (primary_key_string) => JSON.parse(primary_key_string) as [string, string, number, string] - ); + file_key_strings_on_display + .filter((primary_key_string) => is_selected(primary_key_string, get(selected_file_ids))) + .map( + (primary_key_string) => JSON.parse(primary_key_string) as [string, string, number, string] + ); if (selected_file_keys_on_display.length === 0) continue; const selected_file_names_on_display: string[] = ( @@ -379,18 +387,25 @@ export async function create_folder_on_all_selected_displays( } } - export async function get_date_mapping(file_primary_key: string): Promise> { - const same_file_on_displays: FileOnDisplay[] = await db.files_on_display.where('file_primary_key').equals(file_primary_key).toArray(); - const displays_with_that_file: Display[] = await db.displays.where('id').anyOf(same_file_on_displays.map((e) => e.display_id)).toArray(); + const same_file_on_displays: FileOnDisplay[] = await db.files_on_display + .where('file_primary_key') + .equals(file_primary_key) + .toArray(); + const displays_with_that_file: Display[] = await db.displays + .where('id') + .anyOf(same_file_on_displays.map((e) => e.display_id)) + .toArray(); const out: Record = {}; for (const current_file_on_display of same_file_on_displays) { - const current_name = displays_with_that_file.find((e) => e.id === current_file_on_display.display_id)?.name; + const current_name = displays_with_that_file.find( + (e) => e.id === current_file_on_display.display_id + )?.name; if (!current_name) continue; const current_date = current_file_on_display.date_created; out[current_name] = current_date; } return out; -} \ No newline at end of file +} diff --git a/control/frontend/src/lib/ts/stores/thumbnails.ts b/control/frontend/src/lib/ts/stores/thumbnails.ts index d7046b5..f7faeb3 100755 --- a/control/frontend/src/lib/ts/stores/thumbnails.ts +++ b/control/frontend/src/lib/ts/stores/thumbnails.ts @@ -3,6 +3,7 @@ import { get_thumbnail_blob } from '../api_handler'; import { type Inode } from '../types'; import { db } from '../files_display.db'; import { get_file_type } from '../utils'; +import { get_file_by_id } from './files'; export const active_thumbnail_urls: Writable = writable([]); @@ -22,8 +23,9 @@ export async function generate_thumbnail( await db.files.put(folder_element); // save } -export async function get_thumbnail_url(file: Inode): Promise { - if (!file.thumbnail) return null; +export async function get_thumbnail_url(file_primary_key: string): Promise { + const file: Inode | null = await get_file_by_id(file_primary_key); + if (!file || !file.thumbnail) return null; const new_url = URL.createObjectURL(file.thumbnail); active_thumbnail_urls.update((current: string[]) => { current.push(new_url); diff --git a/control/frontend/src/lib/ts/types.ts b/control/frontend/src/lib/ts/types.ts index e574e2e..f911519 100755 --- a/control/frontend/src/lib/ts/types.ts +++ b/control/frontend/src/lib/ts/types.ts @@ -28,6 +28,26 @@ export const supported_file_type_icon: Record = { PDF: FileText }; +export type FileTransferTask = { + id: string; + type: 'upload' | 'download' | 'sync'; + display_id: string; + display_ip: string; + path: string; + file_name: string; + file_primary_key: string; + file?: File; // only if type === 'upload' + bytes_total: number; // if type === 'sync' -> bytes_total = file_size * 2 (1x download + 1x upload) +}; + +export type FileOnDisplay = { + display_id: string; + file_primary_key: string; // JSON.stringify([string, string, number, string]) + date_created: Date; + is_loading: boolean; + percentage: number; +}; + export type Inode = { path: string; name: string; diff --git a/control/frontend/src/lib/ts/utils.ts b/control/frontend/src/lib/ts/utils.ts index 768f2db..34ccf40 100644 --- a/control/frontend/src/lib/ts/utils.ts +++ b/control/frontend/src/lib/ts/utils.ts @@ -6,6 +6,27 @@ const supported_file_types: Record = supported_file_t SupportedFileType >; +const invalid_first_regex = /^[^a-zA-ZäöüßÄÖÜ0-9_]/u; +const invalid_rest_regex = /[^a-zA-ZäöüßÄÖÜ0-9 _\-()$${}.,+$€!?%&=/]/gu; + +export function is_valid_name(input: string): boolean { + return !invalid_rest_regex.test(input) && first_letter_is_valid(input); +} + +export function first_letter_is_valid(input: string): boolean { + if (input.length === 0) return false; + return !invalid_first_regex.test(input); +} + +export function make_valid_name(input: string): string { + const s = input.normalize("NFC"); + if (s.length === 0) return 'Datei'; + let out = s.replace(invalid_rest_regex, "_"); + out = out.replace(invalid_first_regex, "_"); + + return out; +} + 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) { @@ -22,6 +43,10 @@ export function get_file_type(file: Inode): SupportedFileType | null { return null; } +export function get_accepted_file_type_string(): string { + return Object.values(supported_file_types).map((e) => e.mime_type).join(','); +} + export function get_uuid(): string { return crypto.randomUUID(); } @@ -65,10 +90,6 @@ export async function image_content_hash(blob: Blob, size = 32): Promise 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': diff --git a/control/frontend/src/routes/FileView.svelte b/control/frontend/src/routes/FileView.svelte index 3cdbe09..9b6d42f 100755 --- a/control/frontend/src/routes/FileView.svelte +++ b/control/frontend/src/routes/FileView.svelte @@ -29,10 +29,11 @@ import PopUp from '$lib/components/PopUp.svelte'; import { get_file_primary_key, type Inode, type PopupContent } from '$lib/ts/types'; import TextInput from '$lib/components/TextInput.svelte'; - import { is_valid_name } from '$lib/ts/utils'; + import { first_letter_is_valid, get_accepted_file_type_string, is_valid_name } from '$lib/ts/utils'; import { delete_files, rename_file } from '$lib/ts/api_handler'; import HighlightedText from '$lib/components/HighlightedText.svelte'; import { liveQuery, type Observable } from 'dexie'; + import { add_upload } from '$lib/ts/file_transfer_handler'; let current_name: string = $state(''); let current_valid: boolean = $state(false); @@ -57,6 +58,8 @@ closable: true }); + let file_input: HTMLInputElement; + async function get_selected_files(selected_file_ids: string[]): Promise { try { const results = await Promise.all(selected_file_ids.map((id) => get_file_by_id(id))); @@ -159,10 +162,10 @@ bind:current_valid title="Ordnername" 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 (!first_letter_is_valid(trimmed_input)) return [false, `Name darf nicht mit ${trimmed_input[0]} beginnen`]; if (!is_valid_name(trimmed_input)) return [false, 'Name enthält ungültige Zeichen']; if (($current_folder_elements ?? []).some((e) => e.name === trimmed_input)) return [false, 'Name bereits verwendet']; @@ -185,10 +188,10 @@ bind:current_valid title="Neuer {extension === '' ? 'Ordner' : 'Datei'}name" 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 (!first_letter_is_valid(trimmed_input)) return [false, `Name darf nicht mit ${trimmed_input[0]} beginnen`]; if (!is_valid_name(trimmed_input)) return [false, 'Name enthält ungültige Zeichen']; if ( ($current_folder_elements ?? []).some( @@ -241,6 +244,15 @@ {/snippet} + add_upload((e.target as HTMLInputElement).files!, $selected_display_ids, $current_file_path)} +/> +
@@ -267,7 +279,7 @@ change_height('file', -1); }} > - +
@@ -286,6 +298,9 @@