Compare commits

..

7 Commits

8 changed files with 388 additions and 79 deletions

123
app.vue
View File

@ -1,10 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { getElementInfo } from "moveable";
import { VueSelecto } from "vue3-selecto";
import { useDisplay } from 'vuetify/lib/framework.mjs'; import { useDisplay } from 'vuetify/lib/framework.mjs';
import { HistoryManager } from './composables/history-manager'; import { HistoryManager } from './composables/history-manager';
import { FilesManager } from './composables/files-manager'; import { FilesManager, supportedExtensions, imageExtensions } from './composables/files-manager';
import type { iFile } from "composables/worker/7zip-manager" import type { iFile } from "composables/worker/7zip-manager"
import { videoExtensions, binaryExtensions } from '#imports';
let display = useDisplay(); let display = useDisplay();
let drawer = ref(!display.mdAndDown.value); let drawer = ref(!display.mdAndDown.value);
@ -18,13 +17,21 @@ let selectedList = ref<any>([]);
let filesManager = new FilesManager(filesList); let filesManager = new FilesManager(filesList);
let history = new HistoryManager(filesManager); let history = new HistoryManager(filesManager);
let mediaBlobUrl = ref(''); let mediaBlobUrl = ref('');
let errorDialog = ref(false);
let errorMessage = ref('');
watchEffect(async () => { watchEffect(async () => {
if (files.value?.[0]) { if (files.value?.[0]) {
loadingModel.value = true; loadingModel.value = true;
filesList.value = []; filesList.value = [];
await filesManager.loadArchive(files.value?.[0]); try {
await filesManager.loadArchive(files.value?.[0]);
} catch (error: any) {
errorMessage.value = error.message;
errorDialog.value = true;
files.value = [];
}
loadingModel.value = false; loadingModel.value = false;
} }
}) })
@ -57,41 +64,13 @@ watchEffect(async () => {
const file = filesManager.getFile(selectedPath.value); const file = filesManager.getFile(selectedPath.value);
filesGridList.value = file?.content; filesGridList.value = file?.content;
selectedList.value = []; // Update to handle both video and image files
for (const selectedElement of document.querySelectorAll(".selectable.selected")) { if (videoExtensions.includes(filesManager.getFile(selectedPath.value)?.extension?.toLowerCase()) ||
selectedElement.classList.remove("selected"); imageExtensions.includes(filesManager.getFile(selectedPath.value)?.extension?.toLowerCase())) {
}
// Experimental feature
if (videoExtensions.includes(filesManager.getFile(selectedPath.value)?.extension?.toLowerCase())) {
mediaBlobUrl.value = await filesManager.getFileBlobUrl(selectedPath.value) as string; mediaBlobUrl.value = await filesManager.getFileBlobUrl(selectedPath.value) as string;
} }
}) })
const dragContainer = document.querySelector(".select-area");
function onSelectStart(e: any) {
e.added.forEach((el: any) => {
el.classList.add("selected");
selectedList.value = [...selectedList.value, el?.__vnode?.ctx?.props?.value];
});
e.removed.forEach((el: any) => {
el.classList.remove("selected");
selectedList.value = selectedList.value.filter((value: string) => value != el?.__vnode?.ctx?.props?.value)
});
}
function onSelectEnd(e: any) {
e.afterAdded.forEach((el: any) => {
el.classList.add("selected");
selectedList.value = [...selectedList.value, el?.__vnode?.ctx?.props?.value];
});
e.afterRemoved.forEach((el: any) => {
el.classList.remove("selected");
selectedList.value = selectedList.value.filter((value: string) => value != el?.__vnode?.ctx?.props?.value)
});
}
// step up from current path // step up from current path
function stepUp(path: string) { function stepUp(path: string) {
const pathArray = path.split("/"); const pathArray = path.split("/");
@ -123,9 +102,8 @@ function stepUp(path: string) {
</div> </div>
</v-footer> </v-footer>
</v-navigation-drawer> </v-navigation-drawer>
<v-main class="select-area" style="height: 100dvh;"> <v-main style="height: 100dvh;">
<v-toolbar class="px-5" height="auto"> <v-toolbar class="px-5" height="auto">
<v-row align="center" justify="center"> <v-row align="center" justify="center">
<v-col cols="12" lg="2" md="12" style="display: inline-flex;"> <v-col cols="12" lg="2" md="12" style="display: inline-flex;">
<v-btn title="Back" aria-label="Back" icon="mdi-arrow-left" :disabled="!history.hasUndo.value" <v-btn title="Back" aria-label="Back" icon="mdi-arrow-left" :disabled="!history.hasUndo.value"
@ -161,44 +139,27 @@ function stepUp(path: string) {
</v-toolbar> </v-toolbar>
<v-container> <v-container>
<template v-if="filesManager.getFile(selectedPath)?.isFolder"> <template v-if="filesManager.getFile(selectedPath)?.isFolder">
<v-list :selected="[selectedPath]"> <FolderViewer :filesManager="filesManager" :filesGridList="filesGridList" :selectedList="selectedList"></FolderViewer>
<v-row no-gutters>
<v-col cols="6" lg="2" md="3" sm="6" v-for="file of filesGridList" style="text-align: center;">
<v-list-item class="position-relative ma-2 pa-5 selectable" active-color="light-blue-darken-4"
:value="file.path" rounded @click="selectedPath = file.path">
<v-menu v-if="!file.isFolder">
<template v-slot:activator="{ props }">
<v-btn class="position-absolute" style="right: 0; top: 0;" icon="mdi-dots-vertical" variant="text"
v-bind="props"></v-btn>
</template>
<v-list>
<v-list-item title="Download" aria-label="Download" icon="mdi-download"
@click="filesManager.downloadFile(file.path)">
<template v-slot:prepend>
<v-icon icon="mdi-download"></v-icon>
</template>
</v-list-item>
</v-list>
</v-menu>
<file-logo class="mb-2" :file="file" :key="file.path" />
<p>{{ file.name }}</p>
</v-list-item>
</v-col>
</v-row>
</v-list>
</template> </template>
<template <template
v-if="!filesManager.getFile(selectedPath)?.isFolder && videoExtensions.includes(filesManager.getFile(selectedPath)?.extension)"> v-if="!filesManager.getFile(selectedPath)?.isFolder && videoExtensions.includes(filesManager.getFile(selectedPath)?.extension)">
<MediaVideoPlayer :src="mediaBlobUrl"></MediaVideoPlayer> <MediaVideoPlayer :src="mediaBlobUrl"></MediaVideoPlayer>
</template> </template>
<template <template
v-if="!filesManager.getFile(selectedPath)?.isFolder && files.length && !videoExtensions.includes(filesManager.getFile(selectedPath)?.extension)"> v-if="!filesManager.getFile(selectedPath)?.isFolder && imageExtensions.includes(filesManager.getFile(selectedPath)?.extension)">
<TextEditor :file="filesManager.getFile(selectedPath)" :filesManager="filesManager"></TextEditor> <MediaImageViewer :src="mediaBlobUrl"></MediaImageViewer>
</template>
<template
v-if="!filesManager.getFile(selectedPath)?.isFolder && files.length && !imageExtensions.includes(filesManager.getFile(selectedPath)?.extension) && !videoExtensions.includes(filesManager.getFile(selectedPath)?.extension) && !binaryExtensions.includes(filesManager.getFile(selectedPath)?.extension)">
<MediaTextEditor :file="filesManager.getFile(selectedPath)" :filesManager="filesManager"></MediaTextEditor>
</template>
<template
v-if="!filesManager.getFile(selectedPath)?.isFolder && files.length && binaryExtensions.includes(filesManager.getFile(selectedPath)?.extension)">
<MediaBinaryViewer :file="filesManager.getFile(selectedPath)" :filesManager="filesManager"></MediaBinaryViewer>
</template> </template>
<template v-if="!files.length"> <template v-if="!files.length">
<!-- tutorial drag and drop zipped file here and review it securely --> <!-- tutorial drag and drop zipped file here and review it securely -->
<v-row align="center" justify="center"> <v-row align="center" justify="center" style="height: calc(100vh - 120px)">
<v-col cols="12"> <v-col cols="12">
<v-card variant="flat" class="mx-auto" max-width="768"> <v-card variant="flat" class="mx-auto" max-width="768">
<!-- v-icon for file --> <!-- v-icon for file -->
@ -214,17 +175,14 @@ function stepUp(path: string) {
</v-card-text> </v-card-text>
<!-- file input --> <!-- file input -->
<v-file-input class="mx-5" v-model="files" accept=".zip,.7z,.rar,.tar.bz2,.tar.gz,.tar.xz" <v-file-input class="mx-5" v-model="files"
label="or select a file..." variant="outlined"></v-file-input> :accept="supportedExtensions.map(extension => `.${extension}`).join(',')" label="or select a file..."
variant="outlined"></v-file-input>
</v-card> </v-card>
</v-col> </v-col>
</v-row> </v-row>
</template> </template>
</v-container> </v-container>
<VueSelecto :selectableTargets="['.selectable']" :dragContainer="dragContainer" :hitRate="0"
:selectFromInside="false" :toggleContinueSelect="'ctrl'" @select="onSelectStart" @selectStart="onSelectStart"
:get-element-rect="getElementInfo" @selectEnd="onSelectEnd" :select-by-click="false" />
</v-main> </v-main>
<v-dialog v-model="loadingModel" persistent width="auto"> <v-dialog v-model="loadingModel" persistent width="auto">
<v-card> <v-card>
@ -234,6 +192,22 @@ function stepUp(path: string) {
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-dialog> </v-dialog>
<v-dialog v-model="errorDialog" width="auto">
<v-card>
<v-card-title class="text-error">
Error
</v-card-title>
<v-card-text>
{{ errorMessage }}
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text" @click="errorDialog = false">
Close
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-layout> </v-layout>
</template> </template>
<style> <style>
@ -244,4 +218,9 @@ function stepUp(path: string) {
.selected { .selected {
background: rgba(48, 150, 243, 0.1); background: rgba(48, 150, 243, 0.1);
} }
.v-container {
height: calc(100vh - 120px);
padding: 0;
}
</style> </style>

View File

@ -0,0 +1,79 @@
<script lang="ts" setup>
import { getElementInfo } from "moveable";
import { VueSelecto } from "vue3-selecto";
import { FilesManager } from '~/composables/files-manager';
import { iFile } from '~/composables/worker/7zip-manager';
interface Props {
filesManager: FilesManager;
filesGridList: iFile[];
selectedList: Ref<string[]>
};
const { filesManager, filesGridList, selectedList } = defineProps<Props>();
const selectedPath = useSelectedPath();
const dragContainer = document.querySelector(".select-area");
function onSelectStart(e: any) {
e.added.forEach((el: any) => {
el.classList.add("selected");
selectedList.value = [...selectedList.value, el?.__vnode?.ctx?.props?.value];
});
e.removed.forEach((el: any) => {
el.classList.remove("selected");
selectedList.value = selectedList.value.filter((value: string) => value != el?.__vnode?.ctx?.props?.value)
});
}
function onSelectEnd(e: any) {
e.afterAdded.forEach((el: any) => {
el.classList.add("selected");
selectedList.value = [...selectedList.value, el?.__vnode?.ctx?.props?.value];
});
e.afterRemoved.forEach((el: any) => {
el.classList.remove("selected");
selectedList.value = selectedList.value.filter((value: string) => value != el?.__vnode?.ctx?.props?.value)
});
}
watchEffect(() => {
selectedList.value = [];
for (const selectedElement of document.querySelectorAll(".selectable.selected")) {
selectedElement.classList.remove("selected");
}
})
</script>
<template>
<v-list class="select-area" :selected="[selectedPath]" style="height: calc(100vh - 120px);">
<v-row no-gutters>
<v-col cols="6" lg="2" md="3" sm="6" v-for="file of filesGridList" style="text-align: center;">
<v-list-item class="position-relative ma-2 pa-5 selectable" active-color="light-blue-darken-4"
:value="file.path" rounded @click="selectedPath = file.path">
<v-menu v-if="!file.isFolder">
<template v-slot:activator="{ props }">
<v-btn class="position-absolute" style="right: 0; top: 0;" icon="mdi-dots-vertical"
variant="text" v-bind="props"></v-btn>
</template>
<v-list>
<v-list-item title="Download" aria-label="Download" icon="mdi-download"
@click="filesManager.downloadFile(file.path)">
<template v-slot:prepend>
<v-icon icon="mdi-download"></v-icon>
</template>
</v-list-item>
</v-list>
</v-menu>
<file-logo class="mb-2" :file="file" :key="file.path" />
<p>{{ file.name }}</p>
</v-list-item>
</v-col>
</v-row>
<VueSelecto :selectableTargets="['.selectable']" :dragContainer="dragContainer" :hitRate="0"
:selectFromInside="false" :toggleContinueSelect="'ctrl'" @select="onSelectStart" @selectStart="onSelectStart"
:get-element-rect="getElementInfo" @selectEnd="onSelectEnd" :select-by-click="false" />
</v-list>
</template>

View File

@ -0,0 +1,166 @@
<script lang="ts" setup>
import { ref, onMounted, computed } from 'vue'
import { FilesManager } from '~/composables/files-manager';
import { iFile } from '~/composables/worker/7zip-manager';
import { VBtn } from 'vuetify/components';
interface Props {
file: iFile,
filesManager: FilesManager
}
const { file, filesManager } = defineProps<Props>()
const buffer = ref<Uint8Array>()
const containerRef = ref<HTMLElement>()
const isLoading = ref(true)
const rowHeight = 24 // pixels per row
const visibleRows = ref(0)
const scrollTop = ref(0)
// Calculate total rows and visible window
const totalRows = computed(() => buffer.value ? Math.ceil(buffer.value.length / 16) : 0)
const startRow = computed(() => Math.floor(scrollTop.value / rowHeight))
const endRow = computed(() => Math.min(startRow.value + visibleRows.value + 50, totalRows.value))
// Get only the visible rows data
const visibleData = computed(() => {
if (!buffer.value) return []
const rows = []
for (let i = startRow.value; i < endRow.value; i++) {
const offset = i * 16
const chunk = buffer.value.slice(offset, offset + 16)
const hex = Array.from(chunk).map(b => b.toString(16).padStart(2, '0')).join(' ')
const ascii = Array.from(chunk).map(b => b >= 32 && b <= 126 ? String.fromCharCode(b) : '.').join('')
rows.push({
address: offset.toString(16).padStart(8, '0'),
hex: hex.padEnd(48, ' '),
ascii
})
}
return rows
})
// Handle scroll events
function onScroll(event: Event) {
const container = event.target as HTMLElement
scrollTop.value = container.scrollTop
}
// Calculate visible rows based on container height
function updateVisibleRows() {
if (containerRef.value) {
visibleRows.value = Math.ceil(containerRef.value.clientHeight / rowHeight)
}
}
function downloadBinaryFile() {
filesManager.downloadFile(file.path);
}
onMounted(async () => {
try {
buffer.value = await filesManager.getFileContent(file.path, "binary") as Uint8Array
updateVisibleRows()
window.addEventListener('resize', updateVisibleRows)
} finally {
isLoading.value = false
}
})
</script>
<template>
<div class="binary-viewer">
<div class="toolbar">
<v-btn
color="primary"
variant="flat"
prepend-icon="mdi-download"
@click="downloadBinaryFile"
size="small"
>
Download {{ file.name }}
</v-btn>
</div>
<div v-if="isLoading" class="loading">
Loading...
</div>
<div
v-else
ref="containerRef"
class="hex-container"
@scroll="onScroll"
>
<div
class="scroll-content"
:style="{
height: `${totalRows * rowHeight}px`,
paddingTop: `${startRow * rowHeight}px`
}"
>
<div
v-for="row in visibleData"
:key="row.address"
class="hex-line"
>
<span class="address">{{ row.address }}</span>
<span class="hex">{{ row.hex }}</span>
<span class="ascii">{{ row.ascii }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.binary-viewer {
font-family: monospace;
white-space: pre;
padding: 1rem;
background-color: var(--v-theme-surface);
height: calc(100vh - 148px);
}
.hex-container {
height: 100%;
overflow-y: auto;
position: relative;
}
.scroll-content {
position: absolute;
width: 100%;
}
.hex-line {
line-height: 1.5;
height: v-bind(rowHeight + 'px');
}
.address {
color: #666;
margin-right: 1rem;
}
.hex {
margin-right: 1rem;
}
.ascii {
color: #666;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.toolbar {
margin-bottom: 1rem;
display: flex;
justify-content: flex-end;
}
</style>

View File

@ -0,0 +1,71 @@
<script lang="ts" setup>
const props = defineProps<{
src: string;
}>();
const scale = ref(1);
const rotation = ref(0);
function zoomIn() {
scale.value = Math.min(scale.value + 0.1, 3);
}
function zoomOut() {
scale.value = Math.max(scale.value - 0.1, 0.1);
}
function resetZoom() {
scale.value = 1;
rotation.value = 0;
}
function rotateImage() {
rotation.value = (rotation.value + 90) % 360;
}
</script>
<template>
<div class="image-viewer">
<div class="image-controls">
<v-btn icon="mdi-plus" @click="zoomIn" title="Zoom In"></v-btn>
<v-btn icon="mdi-minus" @click="zoomOut" title="Zoom Out"></v-btn>
<v-btn icon="mdi-rotate-right" @click="rotateImage" title="Rotate"></v-btn>
<v-btn icon="mdi-refresh" @click="resetZoom" title="Reset"></v-btn>
</div>
<div class="image-container">
<img :src="src" :style="{
transform: `scale(${scale}) rotate(${rotation}deg)`,
transition: 'transform 0.2s ease-in-out'
}" />
</div>
</div>
</template>
<style scoped>
.image-viewer {
height: 100%;
display: flex;
flex-direction: column;
}
.image-controls {
padding: 8px;
display: flex;
gap: 8px;
justify-content: center;
}
.image-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.image-container img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
</style>

View File

@ -2,7 +2,6 @@
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api' import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { FilesManager } from '~/composables/files-manager'; import { FilesManager } from '~/composables/files-manager';
// @ts-ignore
import { iFile } from '~/composables/worker/7zip-manager'; import { iFile } from '~/composables/worker/7zip-manager';
interface Props { interface Props {
@ -18,6 +17,7 @@ let { file, filesManager } = defineProps<Props>()
onMounted(async () => { onMounted(async () => {
const darkMode = window.matchMedia('(prefers-color-scheme: dark)').matches const darkMode = window.matchMedia('(prefers-color-scheme: dark)').matches
if(!file) return;
let fileContent = await filesManager.getFileContent(file.path); let fileContent = await filesManager.getFileContent(file.path);
monaco.editor.create(editor.value, { monaco.editor.create(editor.value, {
value: fileContent?.toString()!, value: fileContent?.toString()!,
@ -35,6 +35,6 @@ onMounted(async () => {
<style scoped> <style scoped>
#editor { #editor {
width: 100vw; width: 100vw;
height: 100vh; height: calc(100vh - 120px);
} }
</style> </style>

View File

@ -10,7 +10,14 @@ import mime from 'mime';
export const videoExtensions = ['mp4', 'avi', 'mov', 'mkv']; export const videoExtensions = ['mp4', 'avi', 'mov', 'mkv'];
export const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp']; export const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
export const audioExtensions = ['mp3', 'wav', 'ogg', 'flac']; export const audioExtensions = ['mp3', 'wav', 'ogg', 'flac'];
export const textExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'js', 'ts', 'php', 'c', 'cpp', 'py', 'html', 'css', 'scss', 'sass', 'less', 'json', 'xml', 'sql', 'java', 'go', 'rb', 'sh', 'bat', 'ps1', 'cmd', 'yml', 'yaml', 'ini', 'toml', 'csv', 'tsv', 'gitignore', 'lock', 'htaccess', 'htpasswd', 'env', 'dockerfile', 'gitattributes', 'gitmodules', 'editorconfig', 'babelrc', 'eslintrc', 'eslintignore', 'prettierrc', 'prettierignore', 'stylelintrc', 'stylelintignore', 'postcssrc', 'postcss.config', 'jsx', 'tsx', 'license'] export const textExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'js', 'ts', 'php', 'c', 'cpp', 'py', 'html', 'css', 'scss', 'sass', 'less', 'json', 'xml', 'sql', 'java', 'go', 'rb', 'sh', 'bat', 'ps1', 'cmd', 'yml', 'yaml', 'ini', 'toml', 'csv', 'tsv', 'gitignore', 'lock', 'htaccess', 'htpasswd', 'env', 'dockerfile', 'gitattributes', 'gitmodules', 'editorconfig', 'babelrc', 'eslintrc', 'eslintignore', 'prettierrc', 'prettierignore', 'stylelintrc', 'stylelintignore', 'postcssrc', 'postcss.config', 'jsx', 'tsx', 'license'];
export const binaryExtensions = ['exe', 'dll', 'so', 'dylib', 'bin', 'dat', 'db', 'sqlite', 'o', 'class', 'pyc'];
export const supportedExtensions = [
'7z', 'xz', 'bz2', 'gz', 'tar', 'zip', 'wim',
'apfs', 'ar', 'arj', 'cab', 'chm', 'cpio', 'dmg', 'ext', 'fat', 'gpt', 'hfs',
'ihex', 'iso', 'lzh', 'lzma', 'mbr', 'msi', 'nsis', 'ntfs', 'qcow2', 'rar',
'rpm', 'squashfs', 'udf', 'uefi', 'vdi', 'vhd', 'vhdx', 'vmdk', 'xar', 'z'
];
export class FilesManager { export class FilesManager {
consoleOutputBuffer: string[] = []; consoleOutputBuffer: string[] = [];
@ -27,6 +34,12 @@ export class FilesManager {
async loadArchive(file: File) { async loadArchive(file: File) {
if (!this.remoteSevenZipManager) return; if (!this.remoteSevenZipManager) return;
const extension = file.name.split('.').pop()?.toLowerCase();
if (!extension || !supportedExtensions.includes(extension)) {
throw new Error('Unsupported file format. Please use a supported archive format.');
}
this.filesList.value = await this.remoteSevenZipManager.loadArchive(file) || []; this.filesList.value = await this.remoteSevenZipManager.loadArchive(file) || [];
} }

View File

@ -166,9 +166,10 @@ export class SevenZipManager {
async getFileContent(file: iFile, encoding: "utf8" | "binary" = "utf8") { async getFileContent(file: iFile, encoding: "utf8" | "binary" = "utf8") {
if (!this.sevenZip) return; if (!this.sevenZip) return;
file = typeof file === "string" ? JSON.parse(file) : file; file = typeof file === "string" ? JSON.parse(file) : file;
if (!file) return;
// extract file from archive // extract file from archive
this.execute(['x', '-y', this.archiveName, file.path.substring(1)]); this.execute(['x', '-y', this.archiveName, file?.path.substring(1)]);
this.sevenZip.FS.chmod(file.path, 0o777); this.sevenZip.FS.chmod(file.path, 0o777);
// get file buffer // get file buffer

6
package-lock.json generated
View File

@ -2662,9 +2662,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001487", "version": "1.0.30001677",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001487.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001677.tgz",
"integrity": "sha512-83564Z3yWGqXsh2vaH/mhXfEM0wX+NlBCm1jYHOb97TrTWJEmPTccZgeLTPBUUb0PNVo+oomb7wkimZBIERClA==", "integrity": "sha512-fmfjsOlJUpMWu+mAAtZZZHz7UEwsUxIIvu1TJfO1HqFQvB/B+ii0xr9B5HpbZY/mC4XZ8SvjHJqtAY6pDPQEog==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {