Merge pull request #525 from arabcoders/dev

This commit is contained in:
Abdulmohsen 2025-12-29 23:07:07 +03:00 committed by GitHub
commit 714b6d95ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 335 additions and 99 deletions

View file

@ -85,14 +85,14 @@ class YTDLP(yt_dlp.YoutubeDL):
if not (archive_id := self._make_archive_id(info_dict)): if not (archive_id := self._make_archive_id(info_dict)):
return return
assert archive_id assert archive_id, "Expected non-empty archive ID"
self.write_debug(f"Adding to archive: {archive_id}") self.write_debug(f"Adding to archive: {archive_id}")
self.archive.add(archive_id) self.archive.add(archive_id)
old_archive_ids = info_dict.get("_old_archive_ids", []) old_archive_ids = info_dict.get("_old_archive_ids", [])
if old_archive_ids and isinstance(old_archive_ids, list) and len(old_archive_ids) > 0: if old_archive_ids and isinstance(old_archive_ids, list) and len(old_archive_ids) > 0:
for old_id in old_archive_ids: for old_id in old_archive_ids:
if old_id == archive_id or not old_id.startswith("generic "): if old_id == archive_id:
continue continue
self.write_debug(f"Adding to archive (old id): {old_id}") self.write_debug(f"Adding to archive (old id): {old_id}")

View file

@ -1,8 +1,8 @@
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, Mock, patch
from app.library.Utils import REMOVE_KEYS from app.library.Utils import REMOVE_KEYS
from app.library.ytdlp import _ArchiveProxy, ytdlp_options from app.library.ytdlp import YTDLP, _ArchiveProxy, ytdlp_options
class TestArchiveProxy: class TestArchiveProxy:
@ -79,3 +79,200 @@ class TestYtDlpOptions:
# We expect at least one to be present (e.g., -P / --paths, etc.) # We expect at least one to be present (e.g., -P / --paths, etc.)
assert len(present_ignored_flags) > 0 assert len(present_ignored_flags) > 0
assert all(flag_to_ignored[f] is True for f in present_ignored_flags) assert all(flag_to_ignored[f] is True for f in present_ignored_flags)
class TestYTDLP:
"""Test the YTDLP class overridden methods."""
def _create_ytdlp(self, params=None):
"""Helper to create a YTDLP instance with mocked parent __init__."""
with patch("app.library.ytdlp.yt_dlp.YoutubeDL.__init__", return_value=None):
ytdlp = YTDLP(params=params)
ytdlp.params = params or {}
return ytdlp
@patch("app.library.ytdlp.yt_dlp.YoutubeDL.__init__")
def test_init_patches_download_archive_param(self, mock_super_init) -> None:
"""Test that __init__ removes download_archive before calling super, then restores it."""
mock_super_init.return_value = None
params = {"download_archive": "/tmp/archive.txt", "quiet": True}
ytdlp = YTDLP(params=params, auto_init=True)
# Set params as it would be after super().__init__
ytdlp.params = {}
# Verify super().__init__ was called without download_archive
mock_super_init.assert_called_once()
call_kwargs = mock_super_init.call_args[1]
assert "download_archive" not in call_kwargs["params"]
assert call_kwargs["params"]["quiet"] is True
assert call_kwargs["auto_init"] is True
# Our __init__ code manually sets these after super()
ytdlp.params["download_archive"] = "/tmp/archive.txt"
# Verify download_archive was restored to params
assert ytdlp.params["download_archive"] == "/tmp/archive.txt"
# Verify archive proxy was set up
assert isinstance(ytdlp.archive, _ArchiveProxy)
assert ytdlp.archive._file == "/tmp/archive.txt"
@patch("app.library.ytdlp.yt_dlp.YoutubeDL.__init__")
def test_init_handles_no_download_archive(self, mock_super_init) -> None:
"""Test __init__ works correctly when download_archive is not in params."""
mock_super_init.return_value = None
params = {"quiet": True}
ytdlp = YTDLP(params=params, auto_init=False)
# Verify super().__init__ was called with original params
mock_super_init.assert_called_once()
call_kwargs = mock_super_init.call_args[1]
assert call_kwargs["params"]["quiet"] is True
assert call_kwargs["auto_init"] is False
# Verify archive proxy is falsey
assert isinstance(ytdlp.archive, _ArchiveProxy)
assert not ytdlp.archive
@patch("app.library.ytdlp.yt_dlp.YoutubeDL.__init__")
def test_init_handles_none_params(self, mock_super_init) -> None:
"""Test __init__ handles None params gracefully."""
mock_super_init.return_value = None
ytdlp = YTDLP(params=None)
mock_super_init.assert_called_once_with(params=None, auto_init=True)
assert isinstance(ytdlp.archive, _ArchiveProxy)
assert not ytdlp.archive
@patch("app.library.ytdlp.yt_dlp.YoutubeDL._delete_downloaded_files")
def test_delete_downloaded_files_skips_when_interrupted(self, mock_super_delete) -> None:
"""Test _delete_downloaded_files skips cleanup when _interrupted is True."""
with patch("app.library.ytdlp.yt_dlp.YoutubeDL.__init__", return_value=None):
ytdlp = YTDLP(params={})
ytdlp.to_screen = Mock()
# Set interrupted flag
ytdlp._interrupted = True
result = ytdlp._delete_downloaded_files("arg1", "arg2", kwarg1="value1")
# Should not call super method
mock_super_delete.assert_not_called()
# Should show message
ytdlp.to_screen.assert_called_once_with("[info] Cancelled — skipping temp cleanup.")
# Should return None
assert result is None
@patch("app.library.ytdlp.yt_dlp.YoutubeDL._delete_downloaded_files")
def test_delete_downloaded_files_calls_super_when_not_interrupted(self, mock_super_delete) -> None:
"""Test _delete_downloaded_files calls super when not interrupted."""
mock_super_delete.return_value = "cleanup_result"
with patch("app.library.ytdlp.yt_dlp.YoutubeDL.__init__", return_value=None):
ytdlp = YTDLP(params={})
ytdlp._interrupted = False
result = ytdlp._delete_downloaded_files("arg1", kwarg1="value1")
# Should call super method with same args
mock_super_delete.assert_called_once_with("arg1", kwarg1="value1")
# Should return super's result
assert result == "cleanup_result"
def test_record_download_archive_does_nothing_without_download_archive_param(self) -> None:
"""Test record_download_archive returns early when download_archive is not set."""
ytdlp = self._create_ytdlp(params={})
ytdlp.archive = Mock()
ytdlp.record_download_archive({"id": "test123"})
# Should not interact with archive
ytdlp.archive.add.assert_not_called()
def test_record_download_archive_adds_archive_id(self) -> None:
"""Test record_download_archive adds the archive ID."""
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
ytdlp.write_debug = Mock()
ytdlp.archive = Mock()
ytdlp._make_archive_id = Mock(return_value="youtube test123")
info_dict = {"id": "test123", "ie_key": "Youtube"}
ytdlp.record_download_archive(info_dict)
# Verify _make_archive_id was called
ytdlp._make_archive_id.assert_called_once_with(info_dict)
# Verify archive.add was called with the archive_id
ytdlp.archive.add.assert_called_once_with("youtube test123")
ytdlp.write_debug.assert_called_with("Adding to archive: youtube test123")
def test_record_download_archive_handles_old_archive_ids(self) -> None:
"""Test record_download_archive adds _old_archive_ids when present."""
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
ytdlp.write_debug = Mock()
ytdlp.archive = Mock()
ytdlp._make_archive_id = Mock(return_value="youtube new123")
info_dict = {
"id": "new123",
"ie_key": "Youtube",
"_old_archive_ids": ["youtube old123", "youtube old456", "youtube new123"],
}
ytdlp.record_download_archive(info_dict)
# Should add main archive_id
assert ytdlp.archive.add.call_count == 3
calls = [call[0][0] for call in ytdlp.archive.add.call_args_list]
# First call is main archive_id
assert calls[0] == "youtube new123"
# Should add old IDs except the duplicate
assert "youtube old123" in calls
assert "youtube old456" in calls
# Should not add duplicate (youtube new123 appears only once)
assert calls.count("youtube new123") == 1
def test_record_download_archive_skips_empty_old_archive_ids(self) -> None:
"""Test record_download_archive handles empty or invalid _old_archive_ids."""
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
ytdlp.write_debug = Mock()
ytdlp.archive = Mock()
ytdlp._make_archive_id = Mock(return_value="youtube test123")
# Test with empty list
info_dict = {"id": "test123", "ie_key": "Youtube", "_old_archive_ids": []}
ytdlp.record_download_archive(info_dict)
assert ytdlp.archive.add.call_count == 1 # Only main ID
ytdlp.archive.reset_mock()
# Test with None
info_dict = {"id": "test123", "ie_key": "Youtube", "_old_archive_ids": None}
ytdlp.record_download_archive(info_dict)
assert ytdlp.archive.add.call_count == 1 # Only main ID
ytdlp.archive.reset_mock()
# Test with non-list value
info_dict = {"id": "test123", "ie_key": "Youtube", "_old_archive_ids": "not a list"}
ytdlp.record_download_archive(info_dict)
assert ytdlp.archive.add.call_count == 1 # Only main ID
def test_record_download_archive_returns_early_on_empty_archive_id(self) -> None:
"""Test record_download_archive returns early when _make_archive_id returns empty."""
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
ytdlp.archive = Mock()
ytdlp._make_archive_id = Mock(return_value=None)
ytdlp.record_download_archive({"id": "test123"})
# Should not add anything
ytdlp.archive.add.assert_not_called()

View file

@ -9,7 +9,7 @@
<span class="icon"><i class="fa-solid fa-link" /></span> <span class="icon"><i class="fa-solid fa-link" /></span>
<span class="has-tooltip" v-tooltip="'Use Shift+Enter to switch to multiline input mode.'"> <span class="has-tooltip" v-tooltip="'Use Shift+Enter to switch to multiline input mode.'">
URLs separated by newlines or <span class="is-bold is-lowercase">{{ getSeparatorsName(separator) URLs separated by newlines or <span class="is-bold is-lowercase">{{ getSeparatorsName(separator)
}}</span> }}</span>
</span> </span>
</label> </label>
<div class="field is-grouped"> <div class="field is-grouped">
@ -33,11 +33,14 @@
</div> </div>
<div class="column is-4-tablet is-12-mobile"> <div class="column is-4-tablet is-12-mobile">
<label class="label" for="preset">
<span class="icon"><i class="fa-solid fa-sliders" /></span>
<span>Preset</span>
</label>
<div class="field has-addons"> <div class="field has-addons">
<div class="control" @click="show_description = !show_description"> <div class="control" @click="show_description = !show_description">
<label class="button is-static"> <label class="button is-static">
<span class="icon"><i class="fa-solid fa-sliders" /></span> <span class="icon"><i class="fa-solid fa-sliders" /></span>
<span>Preset</span>
</label> </label>
</div> </div>
<div class="control is-expanded"> <div class="control is-expanded">
@ -62,22 +65,25 @@
</div> </div>
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
<div class="field has-addons" v-tooltip="'Folder relative to ' + config.app.download_path"> <label class="label" for="folder">
<div class="control"> <span class="icon"><i class="fa-solid fa-save" /></span>
<span>Download path</span>
</label>
<div class="field has-addons">
<div class="control" v-tooltip="`Download path relative to: ${config.app.download_path}`">
<label class="button is-static"> <label class="button is-static">
<span class="icon"><i class="fa-solid fa-folder" /></span> <span>{{ shortPath(config.app.download_path) }}</span>
<span>Save in</span>
</label> </label>
</div> </div>
<div class="control is-expanded"> <div class="control is-expanded">
<input type="text" class="input is-fullwidth" id="path" v-model="form.folder" <input type="text" class="input is-fullwidth" id="folder" v-model="form.folder"
:placeholder="getDefault('folder', '/')" :disabled="!socket.isConnected || addInProgress" :placeholder="getDefault('folder', '/')" :disabled="!socket.isConnected || addInProgress"
list="folders"> list="folders">
</div> </div>
</div> </div>
</div> </div>
<div class="column"> <div class="column is-align-content-end">
<button type="button" class="button is-info" @click="showAdvanced = !showAdvanced" <button type="button" class="button is-info" @click="showAdvanced = !showAdvanced"
:disabled="!socket.isConnected"> :disabled="!socket.isConnected">
<span class="icon"><i class="fa-solid fa-cog" /></span> <span class="icon"><i class="fa-solid fa-cog" /></span>
@ -161,7 +167,7 @@
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use the <NuxtLink target="_blank" <span>Use the <NuxtLink target="_blank"
to="https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp"> to="https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp">
Recommended addon</NuxtLink> by yt-dlp to export cookies. <span class="has-text-danger">The Recommended addon</NuxtLink> to export cookies. <span class="has-text-danger">The
cookies MUST be in Netscape HTTP Cookie format.</span> cookies MUST be in Netscape HTTP Cookie format.</span>
</span> </span>
</span> </span>
@ -401,12 +407,6 @@ const splitUrls = (urlString: string): Array<string> => {
} }
const addDownload = async () => { const addDownload = async () => {
if (' ' === form.value?.folder) {
toast.warning('The download folder contain only spaces. Resetting to default.')
form.value.folder = ''
await nextTick()
}
if (form.value.folder) { if (form.value.folder) {
form.value.folder = form.value.folder.trim() form.value.folder = form.value.folder.trim()
} }
@ -578,14 +578,9 @@ onMounted(async () => {
form.value.preset = config.app.default_preset form.value.preset = config.app.default_preset
} }
if (' ' === form.value?.folder) { if (form.value?.folder) {
toast.warning('The download folder contain only spaces. Resetting to default.')
form.value.folder = ''
await nextTick()
}
if (form.value.folder) {
form.value.folder = form.value.folder.trim() form.value.folder = form.value.folder.trim()
await nextTick()
} }
if (config.isLoaded() && form.value?.preset && !config.presets.some(p => p.name === form.value.preset)) { if (config.isLoaded() && form.value?.preset && !config.presets.some(p => p.name === form.value.preset)) {
@ -620,6 +615,11 @@ const runCliCommand = async (): Promise<void> => {
return return
} }
if (form.value?.folder) {
form.value.folder = form.value.folder.trim()
await nextTick()
}
const { status } = await dialog.confirmDialog({ const { status } = await dialog.confirmDialog({
title: 'Run CLI Command', title: 'Run CLI Command',
message: `This will generate a yt-dlp command and run it in the console. Continue?`, message: `This will generate a yt-dlp command and run it in the console. Continue?`,
@ -697,11 +697,13 @@ const testDownloadOptions = async (): Promise<void> => {
return return
} }
let form_cli = (form.value?.cli || '').trim() if (form.value?.folder) {
if (' ' === form.value?.folder) { form.value.folder = form.value.folder.trim()
form.value.folder = '' await nextTick()
} }
let form_cli = (form.value?.cli || '').trim()
if (dlFields.value && Object.keys(dlFields.value).length > 0) { if (dlFields.value && Object.keys(dlFields.value).length > 0) {
const joined = [] const joined = []
for (const [key, value] of Object.entries(dlFields.value)) { for (const [key, value] of Object.entries(dlFields.value)) {

View file

@ -1,7 +1,7 @@
<template> <template>
<main class="columns mt-2 is-multiline"> <main class="columns mt-2 is-multiline">
<div class="column is-12"> <div class="column is-12">
<form autocomplete="off" id="presetForm" @submit.prevent="checkInfo()"> <form autocomplete="off" id="presetForm" @submit.prevent="checkInfo">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<div class="card-header-title is-text-overflow is-block"> <div class="card-header-title is-text-overflow is-block">
@ -104,28 +104,32 @@
</div> </div>
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
<div class="field"> <label class="label is-inline" for="folder">
<label class="label is-inline" for="folder"> <span class="icon"><i class="fa-solid fa-save" /></span>
<span class="icon"><i class="fa-solid fa-folder" /></span> download path
Default Download path </label>
</label> <div class="field has-addons">
<div class="control"> <div class="control" v-tooltip="`Full Path: ${config.app.download_path}`">
<span class="button is-static">
<span>{{ shortPath(config.app.download_path) }}</span>
</span>
</div>
<div class="control is-expanded">
<input type="text" class="input" id="folder" placeholder="Leave empty to use default download path" <input type="text" class="input" id="folder" placeholder="Leave empty to use default download path"
v-model="form.folder" :disabled="addInProgress" list="folders"> v-model="form.folder" :disabled="addInProgress" list="folders">
</div> </div>
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use this folder if non is given with URL. Leave empty to use default download path. Default
download path <code>{{ config.app.download_path }}</code>.</span>
</span>
</div> </div>
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use this defined path if non are given with the URL.</span>
</span>
</div> </div>
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
<div class="field"> <div class="field">
<label class="label is-inline" for="output_template"> <label class="label is-inline" for="output_template">
<span class="icon"><i class="fa-solid fa-file" /></span> <span class="icon"><i class="fa-solid fa-file" /></span>
Default Output template Output template
</label> </label>
<div class="control"> <div class="control">
<input type="text" class="input" id="output_template" :disabled="addInProgress" <input type="text" class="input" id="output_template" :disabled="addInProgress"
@ -133,10 +137,7 @@
</div> </div>
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use this output template if non are given with URL. if not set, it will defaults to <span>Use this output template if non are given with URL.</span>
<code>{{ config.app.output_template }}</code>. For more information visit <NuxtLink
href="https://github.com/yt-dlp/yt-dlp#output-template" target="_blank">this page</NuxtLink>.
</span>
</span> </span>
</div> </div>
</div> </div>
@ -277,6 +278,11 @@ const checkInfo = async (): Promise<void> => {
} }
} }
if (form.folder) {
form.folder = form.folder.trim()
await nextTick()
}
if (form.cli && '' !== form.cli) { if (form.cli && '' !== form.cli) {
const options = await convertOptions(form.cli) const options = await convertOptions(form.cli)
if (null === options) { if (null === options) {

View file

@ -191,8 +191,8 @@
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span class="is-bold"> <span class="is-bold">
The CRON timer expression to use for this task. If not set, the task will be disabled. For more The CRON timer expression to use for this task. If not set, the task runner will be disabled. For
information on CRON expressions, see <NuxtLink to="https://crontab.guru/" target="_blank"> more information on CRON expressions, see <NuxtLink to="https://crontab.guru/" target="_blank">
crontab.guru</NuxtLink>. crontab.guru</NuxtLink>.
</span> </span>
</span> </span>
@ -200,21 +200,27 @@
</div> </div>
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
<div class="field"> <label class="label is-inline" for="folder">
<label class="label is-inline" for="folder"> <span class="icon"><i class="fa-solid fa-save" /></span>
<span class="icon"><i class="fa-solid fa-folder" /></span> Download path
Save in </label>
</label> <div class="field has-addons">
<div class="control"> <div class="control" v-tooltip="`Full Path: ${config.app.download_path}`">
<span class="button is-static">
<span>{{ shortPath(config.app.download_path) }}</span>
</span>
</div>
<div class="control is-expanded">
<input type="text" class="input" id="folder" :placeholder="getDefault('folder', '/')" <input type="text" class="input" id="folder" :placeholder="getDefault('folder', '/')"
v-model="form.folder" :disabled="addInProgress" list="folders"> v-model="form.folder" :disabled="addInProgress" list="folders">
</div> </div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span class="is-bold">All folders are sub-folders of
<code>{{ config.app.download_path }}</code>.</span>
</span>
</div> </div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span class="is-bold">
Path relative to the download path, leave empty to use preset or default download path.
</span>
</span>
</div> </div>
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
@ -230,7 +236,9 @@
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span class="is-bold">The template to use for the output file name.</span> <span class="is-bold">
The template to use for the output file name. Leave empty to use preset or default template.
</span>
</span> </span>
</div> </div>
</div> </div>
@ -251,7 +259,8 @@
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span class="is-bold">Some URLs have special handlers to monitor for new content. Like YouTube <span class="is-bold">Some URLs have special handlers to monitor for new content. Like YouTube
channels/playlists.</span> channels/playlists. <span class="has-text-danger">Handlers run regardless of task timer.</span>
</span>
</span> </span>
</div> </div>
</div> </div>
@ -324,6 +333,10 @@
<Message class="is-info" :newStyle="true"> <Message class="is-info" :newStyle="true">
<span> <span>
<ul> <ul>
<li><strong>Tasks:</strong> requires <code>--download-archive</code> in
<code>Command options for yt-dlp</code> can be set via presets or manually. Default presets already
include this option.
</li>
<li><strong>YouTube RSS:</strong> Use <code>channel_id</code> or <code>playlist_id</code> URLs. Other link <li><strong>YouTube RSS:</strong> Use <code>channel_id</code> or <code>playlist_id</code> URLs. Other link
types (custom names, handles, user profiles) are not supported. types (custom names, handles, user profiles) are not supported.
</li> </li>
@ -334,9 +347,6 @@
<li><strong>RSS Monitoring Basics:</strong> Runs hourly independently. Timer controls scheduled downloads to <li><strong>RSS Monitoring Basics:</strong> Runs hourly independently. Timer controls scheduled downloads to
yt-dlp. Disable <code>Enable Handler</code> to disable RSS monitoring. yt-dlp. Disable <code>Enable Handler</code> to disable RSS monitoring.
</li> </li>
<li><strong>Archive Requirement:</strong> RSS monitoring requires <code>--download-archive</code> in
<code>Command options for yt-dlp</code> (set in task or preset).
</li>
</ul> </ul>
</span> </span>
</Message> </Message>
@ -420,6 +430,11 @@ const checkInfo = async (): Promise<void> => {
} }
} }
if (form.folder) {
form.folder = form.folder.trim()
await nextTick()
}
if (form.timer) { if (form.timer) {
try { try {
CronExpressionParser.parse(form.timer) CronExpressionParser.parse(form.timer)

View file

@ -129,7 +129,7 @@
<p :class="{ 'is-text-overflow': !isExpanded(item.id, 'folder'), 'is-clickable': true }" <p :class="{ 'is-text-overflow': !isExpanded(item.id, 'folder'), 'is-clickable': true }"
v-if="item.folder" @click="toggleExpand(item.id, 'folder')" v-if="item.folder" @click="toggleExpand(item.id, 'folder')"
:title="!isExpanded(item.id, 'folder') ? 'Click to expand' : 'Click to collapse'"> :title="!isExpanded(item.id, 'folder') ? 'Click to expand' : 'Click to collapse'">
<span class="icon"><i class="fa-solid fa-folder" /></span> <span class="icon"><i class="fa-solid fa-save" /></span>
<span>{{ calcPath(item.folder) }}</span> <span>{{ calcPath(item.folder) }}</span>
</p> </p>
<p :class="{ 'is-text-overflow': !isExpanded(item.id, 'template'), 'is-clickable': true }" <p :class="{ 'is-text-overflow': !isExpanded(item.id, 'template'), 'is-clickable': true }"

View file

@ -332,7 +332,7 @@
<span v-else class="has-text-danger">No timer or handler</span> <span v-else class="has-text-danger">No timer or handler</span>
</p> </p>
<p class="is-text-overflow" v-if="item.folder"> <p class="is-text-overflow" v-if="item.folder">
<span class="icon"><i class="fa-solid fa-folder" /></span> <span class="icon"><i class="fa-solid fa-save" /></span>
<span>{{ calcPath(item.folder) }}</span> <span>{{ calcPath(item.folder) }}</span>
</p> </p>
<p class="is-text-overflow" v-if="item.template"> <p class="is-text-overflow" v-if="item.template">

View file

@ -703,11 +703,26 @@ const stripPath = (base_path: string, real_path: string): string => {
return real_path.replace(base_path, '').replace(/^\//, '') return real_path.replace(base_path, '').replace(/^\//, '')
} }
const shortPath = (path: string, prefix: string = '...'): string => {
if (typeof path !== 'string') {
return path;
}
const hasTrailingSlash = /\/$/.test(path);
const clean = path.replace(/\/+$/, '');
const parts = clean.split('/').filter(Boolean);
if (parts.length <= 1) {
return path;
}
return `${prefix}/${parts.at(-1)}${hasTrailingSlash ? '/' : ''}`;
}
export { export {
separators, convertCliOptions, getSeparatorsName, iTrim, eTrim, sTrim, ucFirst, separators, convertCliOptions, getSeparatorsName, iTrim, eTrim, sTrim, ucFirst,
getValue, ag, ag_set, awaitElement, r, copyText, dEvent, makePagination, encodePath, getValue, ag, ag_set, awaitElement, r, copyText, dEvent, makePagination, encodePath,
request, removeANSIColors, dec2hex, makeId, basename, dirname, getQueryParams, request, removeANSIColors, dec2hex, makeId, basename, dirname, getQueryParams,
makeDownload, formatBytes, has_data, toggleClass, cleanObject, uri, formatTime, makeDownload, formatBytes, has_data, toggleClass, cleanObject, uri, formatTime,
sleep, awaiter, encode, decode, disableOpacity, enableOpacity, stripPath sleep, awaiter, encode, decode, disableOpacity, enableOpacity, stripPath, shortPath
} }

View file

@ -34,7 +34,7 @@
"moment": "^2.30.1", "moment": "^2.30.1",
"nuxt": "^4.2.2", "nuxt": "^4.2.2",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"socket.io-client": "^4.8.2", "socket.io-client": "^4.8.3",
"vue": "^3.5.26", "vue": "^3.5.26",
"vue-router": "^4.6.4", "vue-router": "^4.6.4",
"vue-toastification": "2.0.0-rc.5" "vue-toastification": "2.0.0-rc.5"

View file

@ -57,8 +57,8 @@ importers:
specifier: ^3.0.4 specifier: ^3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)) version: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))
socket.io-client: socket.io-client:
specifier: ^4.8.2 specifier: ^4.8.3
version: 4.8.2 version: 4.8.3
vue: vue:
specifier: ^3.5.26 specifier: ^3.5.26
version: 3.5.26(typescript@5.9.3) version: 3.5.26(typescript@5.9.3)
@ -1267,8 +1267,8 @@ packages:
'@rolldown/pluginutils@1.0.0-beta.53': '@rolldown/pluginutils@1.0.0-beta.53':
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
'@rolldown/pluginutils@1.0.0-beta.9-commit.d91dfb5': '@rolldown/pluginutils@1.0.0-beta.57':
resolution: {integrity: sha512-8sExkWRK+zVybw3+2/kBkYBFeLnEUWz1fT7BLHplpzmtqkOfTbAQ9gkt4pzwGIIZmg4Qn5US5ACjUBenrhezwQ==} resolution: {integrity: sha512-aQNelgx14tGA+n2tNSa9x6/jeoCL9fkDeCei7nOKnHx0fEFRRMu5ReiITo+zZD5TzWDGGRjbSYCs93IfRIyTuQ==}
'@rollup/plugin-alias@5.1.1': '@rollup/plugin-alias@5.1.1':
resolution: {integrity: sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==} resolution: {integrity: sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==}
@ -1577,8 +1577,8 @@ packages:
resolution: {integrity: sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==} resolution: {integrity: sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@unhead/vue@2.0.19': '@unhead/vue@2.1.1':
resolution: {integrity: sha512-7BYjHfOaoZ9+ARJkT10Q2TjnTUqDXmMpfakIAsD/hXiuff1oqWg1xeXT5+MomhNcC15HbiABpbbBmITLSHxdKg==} resolution: {integrity: sha512-WYa8ORhfv7lWDSoNpkMKhbW1Dbsux/3HqMcVkZS3xZ2/c/VrcChLj+IMadpCd1WNR0srITfRJhBYZ1i9hON5Qw==}
peerDependencies: peerDependencies:
vue: '>=3.5.18' vue: '>=3.5.18'
@ -1690,11 +1690,11 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
'@vitejs/plugin-vue-jsx@5.1.2': '@vitejs/plugin-vue-jsx@5.1.3':
resolution: {integrity: sha512-3a2BOryRjG/Iih87x87YXz5c8nw27eSlHytvSKYfp8ZIsp5+FgFQoKeA7k2PnqWpjJrv6AoVTMnvmuKUXb771A==} resolution: {integrity: sha512-I6Zr8cYVr5WHMW5gNOP09DNqW9rgO8RX73Wa6Czgq/0ndpTfJM4vfDChfOT1+3KtdrNqilNBtNlFwVeB02ZzGw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies: peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
vue: ^3.0.0 vue: ^3.0.0
'@vitejs/plugin-vue@6.0.3': '@vitejs/plugin-vue@6.0.3':
@ -1877,8 +1877,8 @@ packages:
ajv@6.12.6: ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
alien-signals@3.1.1: alien-signals@3.1.2:
resolution: {integrity: sha512-ogkIWbVrLwKtHY6oOAXaYkAxP+cTH7V5FZ5+Tm4NZFd8VDZ6uNMDrfzqctTZ42eTMCSR3ne3otpcxmqSnFfPYA==} resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==}
ansi-regex@5.0.1: ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
@ -3831,8 +3831,8 @@ packages:
serialize-javascript@6.0.2: serialize-javascript@6.0.2:
resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
seroval@1.4.1: seroval@1.4.2:
resolution: {integrity: sha512-9GOc+8T6LN4aByLN75uRvMbrwY5RDBW6lSlknsY4LEa9ZmWcxKcRe1G/Q3HZXjltxMHTrStnvrwAICxZrhldtg==} resolution: {integrity: sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
serve-placeholder@2.0.2: serve-placeholder@2.0.2:
@ -3881,8 +3881,8 @@ packages:
smob@1.5.0: smob@1.5.0:
resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==} resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==}
socket.io-client@4.8.2: socket.io-client@4.8.3:
resolution: {integrity: sha512-4MY14EMsyEPFA6lM01XIYepRdV8P6dUir2hxAlAysOYcbNAy5QNHYgIHOcQ1KYM7wTcKnKEW/ZRoIxRinWRXvA==} resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
socket.io-parser@4.2.5: socket.io-parser@4.2.5:
@ -4130,8 +4130,8 @@ packages:
unenv@2.0.0-rc.24: unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
unhead@2.0.19: unhead@2.1.1:
resolution: {integrity: sha512-gEEjkV11Aj+rBnY6wnRfsFtF2RxKOLaPN4i+Gx3UhBxnszvV6ApSNZbGk7WKyy/lErQ6ekPN63qdFL7sa1leow==} resolution: {integrity: sha512-NOt8n2KybAOxSLfNXegAVai4SGU8bPKqWnqCzNAvnRH2i8mW+0bbFjN/L75LBgCSTiOjJSpANe5w2V34Grr7Cw==}
unicorn-magic@0.3.0: unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
@ -4456,6 +4456,7 @@ packages:
whatwg-encoding@3.1.1: whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-mimetype@4.0.0: whatwg-mimetype@4.0.0:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
@ -5421,7 +5422,7 @@ snapshots:
dependencies: dependencies:
'@nuxt/devalue': 2.0.2 '@nuxt/devalue': 2.0.2
'@nuxt/kit': 4.2.2(magicast@0.5.1) '@nuxt/kit': 4.2.2(magicast@0.5.1)
'@unhead/vue': 2.0.19(vue@3.5.26(typescript@5.9.3)) '@unhead/vue': 2.1.1(vue@3.5.26(typescript@5.9.3))
'@vue/shared': 3.5.26 '@vue/shared': 3.5.26
consola: 3.4.2 consola: 3.4.2
defu: 6.1.4 defu: 6.1.4
@ -5511,7 +5512,7 @@ snapshots:
'@nuxt/kit': 4.2.2(magicast@0.5.1) '@nuxt/kit': 4.2.2(magicast@0.5.1)
'@rollup/plugin-replace': 6.0.3(rollup@4.54.0) '@rollup/plugin-replace': 6.0.3(rollup@4.54.0)
'@vitejs/plugin-vue': 6.0.3(vite@7.3.0(@types/node@22.18.1)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3)) '@vitejs/plugin-vue': 6.0.3(vite@7.3.0(@types/node@22.18.1)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))
'@vitejs/plugin-vue-jsx': 5.1.2(vite@7.3.0(@types/node@22.18.1)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3)) '@vitejs/plugin-vue-jsx': 5.1.3(vite@7.3.0(@types/node@22.18.1)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))
autoprefixer: 10.4.23(postcss@8.5.6) autoprefixer: 10.4.23(postcss@8.5.6)
consola: 3.4.2 consola: 3.4.2
cssnano: 7.1.2(postcss@8.5.6) cssnano: 7.1.2(postcss@8.5.6)
@ -5531,7 +5532,7 @@ snapshots:
pkg-types: 2.3.0 pkg-types: 2.3.0
postcss: 8.5.6 postcss: 8.5.6
rollup-plugin-visualizer: 6.0.5(rollup@4.54.0) rollup-plugin-visualizer: 6.0.5(rollup@4.54.0)
seroval: 1.4.1 seroval: 1.4.2
std-env: 3.10.0 std-env: 3.10.0
ufo: 1.6.1 ufo: 1.6.1
unenv: 2.0.0-rc.24 unenv: 2.0.0-rc.24
@ -5799,7 +5800,7 @@ snapshots:
'@rolldown/pluginutils@1.0.0-beta.53': {} '@rolldown/pluginutils@1.0.0-beta.53': {}
'@rolldown/pluginutils@1.0.0-beta.9-commit.d91dfb5': {} '@rolldown/pluginutils@1.0.0-beta.57': {}
'@rollup/plugin-alias@5.1.1(rollup@4.54.0)': '@rollup/plugin-alias@5.1.1(rollup@4.54.0)':
optionalDependencies: optionalDependencies:
@ -6072,10 +6073,10 @@ snapshots:
'@typescript-eslint/types': 8.50.1 '@typescript-eslint/types': 8.50.1
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
'@unhead/vue@2.0.19(vue@3.5.26(typescript@5.9.3))': '@unhead/vue@2.1.1(vue@3.5.26(typescript@5.9.3))':
dependencies: dependencies:
hookable: 5.5.3 hookable: 5.5.3
unhead: 2.0.19 unhead: 2.1.1
vue: 3.5.26(typescript@5.9.3) vue: 3.5.26(typescript@5.9.3)
'@unrs/resolver-binding-android-arm-eabi@1.11.1': '@unrs/resolver-binding-android-arm-eabi@1.11.1':
@ -6156,12 +6157,12 @@ snapshots:
- rollup - rollup
- supports-color - supports-color
'@vitejs/plugin-vue-jsx@5.1.2(vite@7.3.0(@types/node@22.18.1)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))': '@vitejs/plugin-vue-jsx@5.1.3(vite@7.3.0(@types/node@22.18.1)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))':
dependencies: dependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.5
'@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5)
'@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.5) '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.5)
'@rolldown/pluginutils': 1.0.0-beta.9-commit.d91dfb5 '@rolldown/pluginutils': 1.0.0-beta.57
'@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.28.5) '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.28.5)
vite: 7.3.0(@types/node@22.18.1)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.2) vite: 7.3.0(@types/node@22.18.1)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.2)
vue: 3.5.26(typescript@5.9.3) vue: 3.5.26(typescript@5.9.3)
@ -6345,7 +6346,7 @@ snapshots:
'@volar/language-core': 2.4.27 '@volar/language-core': 2.4.27
'@vue/compiler-dom': 3.5.26 '@vue/compiler-dom': 3.5.26
'@vue/shared': 3.5.26 '@vue/shared': 3.5.26
alien-signals: 3.1.1 alien-signals: 3.1.2
muggle-string: 0.4.1 muggle-string: 0.4.1
path-browserify: 1.0.1 path-browserify: 1.0.1
picomatch: 4.0.3 picomatch: 4.0.3
@ -6427,7 +6428,7 @@ snapshots:
json-schema-traverse: 0.4.1 json-schema-traverse: 0.4.1
uri-js: 4.4.1 uri-js: 4.4.1
alien-signals@3.1.1: {} alien-signals@3.1.2: {}
ansi-regex@5.0.1: {} ansi-regex@5.0.1: {}
@ -7947,7 +7948,7 @@ snapshots:
'@nuxt/schema': 4.2.2 '@nuxt/schema': 4.2.2
'@nuxt/telemetry': 2.6.6(magicast@0.5.1) '@nuxt/telemetry': 2.6.6(magicast@0.5.1)
'@nuxt/vite-builder': 4.2.2(@types/node@22.18.1)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.1)(nuxt@4.2.2(@parcel/watcher@2.5.1)(@types/node@22.18.1)(@vue/compiler-sfc@3.5.26)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.8.2)(magicast@0.5.1)(optionator@0.9.4)(rollup@4.54.0)(terser@5.44.1)(typescript@5.9.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.2))(vue-tsc@3.2.1(typescript@5.9.3))(yaml@2.8.2))(optionator@0.9.4)(rollup@4.54.0)(terser@5.44.1)(typescript@5.9.3)(vue-tsc@3.2.1(typescript@5.9.3))(vue@3.5.26(typescript@5.9.3))(yaml@2.8.2) '@nuxt/vite-builder': 4.2.2(@types/node@22.18.1)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.1)(nuxt@4.2.2(@parcel/watcher@2.5.1)(@types/node@22.18.1)(@vue/compiler-sfc@3.5.26)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.8.2)(magicast@0.5.1)(optionator@0.9.4)(rollup@4.54.0)(terser@5.44.1)(typescript@5.9.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.2))(vue-tsc@3.2.1(typescript@5.9.3))(yaml@2.8.2))(optionator@0.9.4)(rollup@4.54.0)(terser@5.44.1)(typescript@5.9.3)(vue-tsc@3.2.1(typescript@5.9.3))(vue@3.5.26(typescript@5.9.3))(yaml@2.8.2)
'@unhead/vue': 2.0.19(vue@3.5.26(typescript@5.9.3)) '@unhead/vue': 2.1.1(vue@3.5.26(typescript@5.9.3))
'@vue/shared': 3.5.26 '@vue/shared': 3.5.26
c12: 3.3.3(magicast@0.5.1) c12: 3.3.3(magicast@0.5.1)
chokidar: 5.0.0 chokidar: 5.0.0
@ -8628,7 +8629,7 @@ snapshots:
dependencies: dependencies:
randombytes: 2.1.0 randombytes: 2.1.0
seroval@1.4.1: {} seroval@1.4.2: {}
serve-placeholder@2.0.2: serve-placeholder@2.0.2:
dependencies: dependencies:
@ -8677,7 +8678,7 @@ snapshots:
smob@1.5.0: {} smob@1.5.0: {}
socket.io-client@4.8.2: socket.io-client@4.8.3:
dependencies: dependencies:
'@socket.io/component-emitter': 3.1.2 '@socket.io/component-emitter': 3.1.2
debug: 4.4.3 debug: 4.4.3
@ -8928,7 +8929,7 @@ snapshots:
dependencies: dependencies:
pathe: 2.0.3 pathe: 2.0.3
unhead@2.0.19: unhead@2.1.1:
dependencies: dependencies:
hookable: 5.5.3 hookable: 5.5.3