chore(control): add basic upload logic

This commit is contained in:
E44
2026-01-04 18:27:54 +01:00
parent 5f9bd20c06
commit 9843565c7b
8 changed files with 268 additions and 33 deletions
@@ -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<Record<string, Date>> = liveQuery(() =>
get_date_mapping(get_file_primary_key(file))
@@ -46,8 +47,8 @@
function get_created_info(date_mapping: Record<string, Date> | 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);
@@ -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<void> {
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<void>((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();
}
@@ -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<Inode, [string, string, number, string]>;
+26 -11
View File
@@ -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<string> = writable<string>('/');
@@ -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,14 +387,21 @@ export async function create_folder_on_all_selected_displays(
}
}
export async function get_date_mapping(file_primary_key: string): Promise<Record<string, Date>> {
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<string, Date> = {};
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;
@@ -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<string[]> = writable<string[]>([]);
@@ -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<string | null> {
if (!file.thumbnail) return null;
export async function get_thumbnail_url(file_primary_key: string): Promise<string | null> {
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);
+20
View File
@@ -28,6 +28,26 @@ export const supported_file_type_icon: Record<string, typeof X> = {
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;
+25 -4
View File
@@ -6,6 +6,27 @@ const supported_file_types: Record<string, SupportedFileType> = 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<number>
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':
+19 -4
View File
@@ -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<Inode[]> {
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 @@
</div>
{/snippet}
<input
class="hidden"
type="file"
bind:this={file_input}
multiple
accept={get_accepted_file_type_string()}
onchange={(e) => add_upload((e.target as HTMLInputElement).files!, $selected_display_ids, $current_file_path)}
/>
<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">
@@ -267,7 +279,7 @@
change_height('file', -1);
}}
>
<ZoomOut class="size-full"/>
<ZoomOut class="size-full" />
</Button>
</div>
</div>
@@ -286,6 +298,9 @@
<Button
title="Datei(en) hochladen"
className="px-3 flex"
click_function={() => {
if (file_input) file_input.click();
}}
disabled={$selected_display_ids.length === 0}><Upload /></Button
>
<Button