fix(csv-viewer): add support for missing text encodings in dropdown list (#5795)

This commit is contained in:
Ariel Leyva
2026-03-06 09:26:13 -05:00
committed by GitHub
parent c950a57df8
commit 4af3f85e64
7 changed files with 439 additions and 17 deletions

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
import { vClickOutside } from "@/utils/index";
const props = withDefaults(
defineProps<{
position?: "top-left" | "bottom-left" | "bottom-right" | "top-right";
closeOnClick?: boolean;
}>(),
{
position: "bottom-right",
closeOnClick: true,
}
);
const isOpen = defineModel<boolean>();
const triggerRef = ref<HTMLElement | null>(null);
const listRef = ref<HTMLElement | null>(null);
const dropdownStyle = ref<Record<string, string>>({});
const updatePosition = () => {
if (!isOpen.value || !triggerRef.value) return;
const rect = triggerRef.value.getBoundingClientRect();
dropdownStyle.value = {
position: "fixed",
top: props.position.includes("bottom")
? `${rect.bottom + 2}px`
: `${rect.top}px`,
left: props.position.includes("left") ? `${rect.left}px` : "auto",
right: props.position.includes("right")
? `${window.innerWidth - rect.right}px`
: "auto",
zIndex: "11000",
};
};
watch(isOpen, (open) => {
if (open) {
updatePosition();
}
});
const onWindowChange = () => {
updatePosition();
};
const closeDropdown = (e: Event) => {
if (
e.target instanceof HTMLElement &&
listRef.value?.contains(e.target) &&
!props.closeOnClick
) {
return;
}
isOpen.value = false;
};
onMounted(() => {
window.addEventListener("resize", onWindowChange);
window.addEventListener("scroll", onWindowChange, true);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", onWindowChange);
window.removeEventListener("scroll", onWindowChange, true);
});
</script>
<script lang="ts">
export default {
directives: {
clickOutside: vClickOutside,
},
};
</script>
<template>
<div
class="dropdown-modal-container"
v-click-outside="closeDropdown"
ref="triggerRef"
>
<button @click="isOpen = !isOpen" class="dropdown-modal-trigger">
<slot></slot>
<i class="material-icons">chevron_right</i>
</button>
<teleport to="body">
<div
ref="listRef"
class="dropdown-modal-list"
:class="{ 'dropdown-modal-open': isOpen }"
:style="dropdownStyle"
>
<div>
<slot name="list"></slot>
</div>
</div>
</teleport>
</div>
</template>
<style scoped>
.dropdown-modal-trigger {
background: var(--surfacePrimary);
color: var(--textSecondary);
border: 1px solid var(--borderPrimary);
border-radius: 0.1em;
padding: 0.5em 1em;
transition: 0.2s ease all;
margin: 0;
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.dropdown-modal-trigger > i {
transform: rotate(90deg);
}
.dropdown-modal-list {
padding: 0.25rem;
background-color: var(--surfacePrimary);
color: var(--textSecondary);
display: none;
border: 1px solid var(--borderPrimary);
border-radius: 0.1em;
}
.dropdown-modal-list > div {
max-height: 450px;
padding: 0.25rem;
}
.dropdown-modal-open {
display: block;
}
</style>

View File

@@ -21,19 +21,37 @@
</div> </div>
<div class="header-select" v-if="isEncodedContent"> <div class="header-select" v-if="isEncodedContent">
<label for="fileEncoding">{{ $t("files.fileEncoding") }}</label> <label for="fileEncoding">{{ $t("files.fileEncoding") }}</label>
<select <DropdownModal
id="fileEncoding" v-model="isEncondingDropdownOpen"
class="input input--block" :close-on-click="false"
v-model="selectedEncoding"
> >
<option <div>
v-for="encoding in availableEncodings" <span class="selected-encoding">{{ selectedEncoding }}</span>
:value="encoding" </div>
:key="encoding" <template v-slot:list>
> <input
{{ encoding }} v-model="encodingSearch"
</option> :placeholder="$t('search.search')"
</select> class="input input--block"
name="encoding"
/>
<div class="encoding-list">
<div v-if="encodingList.length == 0" class="message">
<i class="material-icons">sentiment_dissatisfied</i>
<span>{{ $t("files.lonely") }}</span>
</div>
<button
v-for="encoding in encodingList"
:value="encoding"
:key="encoding"
class="encoding-button"
@click="selectedEncoding = encoding"
>
{{ encoding }}
</button>
</div>
</template>
</DropdownModal>
</div> </div>
</div> </div>
<div v-if="displayError" class="csv-error"> <div v-if="displayError" class="csv-error">
@@ -74,10 +92,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watchEffect } from "vue"; import { computed, ref, watch, watchEffect } from "vue";
import { parse } from "csv-parse/browser/esm"; import { parse } from "csv-parse/browser/esm";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { availableEncodings, decode } from "@/utils/encodings"; import { availableEncodings, decode } from "@/utils/encodings";
import DropdownModal from "../DropdownModal.vue";
const { t } = useI18n({}); const { t } = useI18n({});
@@ -90,6 +109,16 @@ const props = withDefaults(defineProps<Props>(), {
error: "", error: "",
}); });
const isEncondingDropdownOpen = ref(false);
const encodingSearch = ref<string>("");
const encodingList = computed(() => {
return availableEncodings.filter((e) =>
e.toLowerCase().includes(encodingSearch.value.toLowerCase())
);
});
const columnSeparator = ref([",", ";"]); const columnSeparator = ref([",", ";"]);
const selectedEncoding = ref("utf-8"); const selectedEncoding = ref("utf-8");
@@ -128,6 +157,11 @@ watchEffect(() => {
); );
} }
}); });
watch(selectedEncoding, () => {
isEncondingDropdownOpen.value = false;
encodingSearch.value = "";
});
</script> </script>
<style scoped> <style scoped>
@@ -271,14 +305,21 @@ watchEffect(() => {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
flex-direction: column;
@media (width >= 640px) {
flex-direction: row;
}
} }
.header-select > label { .header-select > label {
font-size: small; font-size: small;
max-width: 80px; @media (width >= 640px) {
max-width: 70px;
}
} }
.header-select > select { .header-select > select,
.header-select > div {
margin-bottom: 0; margin-bottom: 0;
} }
@@ -286,4 +327,40 @@ watchEffect(() => {
font-size: 1.2rem; font-size: 1.2rem;
color: var(--blue); color: var(--blue);
} }
.encoding-list {
max-height: 300px;
min-width: 120px;
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
}
.encoding-button {
background-color: transparent;
border: none;
outline: none;
padding: 0.25rem 0.5rem;
color: var(--textPrimary);
text-align: left;
cursor: pointer;
border-radius: 0.2rem;
white-space: nowrap;
display: block;
width: 100%;
}
.encoding-button:hover {
background-color: var(--surfaceSecondary);
}
.selected-encoding {
white-space: nowrap;
text-overflow: ellipsis;
}
.message {
font-size: 1.25em;
}
</style> </style>

View File

@@ -10,4 +10,8 @@ declare global {
// TODO: no idea what the exact type is // TODO: no idea what the exact type is
__vue__: any; __vue__: any;
} }
interface HTMLElement {
clickOutsideEvent?: (event: Event) => void;
}
} }

View File

@@ -1,4 +1,5 @@
export const availableEncodings = [ export const availableEncodings = [
// encodings
"utf-8", "utf-8",
"ibm866", "ibm866",
"iso-8859-2", "iso-8859-2",
@@ -37,6 +38,178 @@ export const availableEncodings = [
"euc-kr", "euc-kr",
"utf-16be", "utf-16be",
"utf-16le", "utf-16le",
// label encodings
"unicode-1-1-utf-8",
"utf8",
"866",
"cp866",
"csibm866",
"csisolatin2",
"iso-ir-101",
"iso8859-2",
"iso88592",
"iso_8859-2",
"iso_8859-2:1987",
"l2",
"latin2",
"csisolatin3",
"iso-ir-109",
"iso8859-3",
"iso88593",
"iso_8859-3",
"iso_8859-3:1988",
"l3",
"latin3",
"csisolatin4",
"iso-ir-110",
"iso8859-4",
"iso88594",
"iso_8859-4",
"iso_8859-4:1988",
"l4",
"latin4",
"csisolatincyrillic",
"cyrillic",
"iso-ir-144",
"iso88595",
"iso_8859-5",
"iso_8859-5:1988",
"arabic",
"asmo-708",
"csiso88596e",
"csiso88596i",
"csisolatinarabic",
"ecma-114",
"iso-8859-6-e",
"iso-8859-6-i",
"iso-ir-127",
"iso8859-6",
"iso88596",
"iso_8859-6",
"iso_8859-6:1987",
"csisolatingreek",
"ecma-118",
"elot_928",
"greek",
"greek8",
"iso-ir-126",
"iso8859-7",
"iso88597",
"iso_8859-7",
"iso_8859-7:1987",
"sun_eu_greek",
"csiso88598e",
"csisolatinhebrew",
"hebrew",
"iso-8859-8-e",
"iso-ir-138",
"iso8859-8",
"iso88598",
"iso_8859-8",
"iso_8859-8:1988",
"visual",
"csiso88598i",
"logical",
"csisolatin6",
"iso-ir-157",
"iso8859-10",
"iso885910",
"l6",
"latin6",
"iso8859-13",
"iso885913",
"iso8859-14",
"iso885914",
"csisolatin9",
"iso8859-15",
"iso885915",
"l9",
"latin9",
"cskoi8r",
"koi",
"koi8",
"koi8_r",
"csmacintosh",
"mac",
"x-mac-roman",
"dos-874",
"iso-8859-11",
"iso8859-11",
"iso885911",
"tis-620",
"cp1250",
"x-cp1250",
"cp1251",
"x-cp1251",
"ansi_x3.4-1968",
"ascii",
"cp1252",
"cp819",
"csisolatin1",
"ibm819",
"iso-8859-1",
"iso-ir-100",
"iso8859-1",
"iso88591",
"iso_8859-1",
"iso_8859-1:1987",
"l1",
"latin1",
"us-ascii",
"x-cp1252",
"cp1253",
"x-cp1253",
"cp1254",
"csisolatin5",
"iso-8859-9",
"iso-ir-148",
"iso8859-9",
"iso88599",
"iso_8859-9",
"iso_8859-9:1989",
"l5",
"latin5",
"x-cp1254",
"cp1255",
"x-cp1255",
"cp1256",
"x-cp1256",
"cp1257",
"x-cp1257",
"cp1258",
"x-cp1258",
"x-mac-ukrainian",
"chinese",
"csgb2312",
"csiso58gb231280",
"gb2312",
"gb_2312",
"gb_2312-80",
"iso-ir-58",
"x-gbk",
"big5-hkscs",
"cn-big5",
"csbig5",
"x-x-big5",
"cseucpkdfmtjapanese",
"x-euc-jp",
"csiso2022jp",
"csshiftjis",
"ms_kanji",
"shift-jis",
"sjis",
"windows-31j",
"x-sjis",
"cseuckr",
"csksc56011987",
"iso-ir-149",
"korean",
"ks_c_5601-1987",
"ks_c_5601-1989",
"ksc5601",
"ksc_5601",
"windows-949",
"utf-16",
]; ];
export function decode(content: ArrayBuffer, encoding: string): string { export function decode(content: ArrayBuffer, encoding: string): string {

View File

@@ -4,3 +4,25 @@ import { partial } from "filesize";
* Formats filesize as KiB/MiB/... * Formats filesize as KiB/MiB/...
*/ */
export const filesize = partial({ base: 2 }); export const filesize = partial({ base: 2 });
export const vClickOutside = {
created(el: HTMLElement, binding: any) {
el.clickOutsideEvent = (event: Event) => {
const target = event.target;
if (target instanceof Node) {
if (!el.contains(target)) {
binding.value(event);
}
}
};
document.addEventListener("click", el.clickOutsideEvent);
},
unmounted(el: HTMLElement) {
if (el.clickOutsideEvent) {
document.removeEventListener("click", el.clickOutsideEvent);
}
},
};

View File

@@ -311,7 +311,11 @@ const isPdf = computed(() => fileStore.req?.extension.toLowerCase() == ".pdf");
const isEpub = computed( const isEpub = computed(
() => fileStore.req?.extension.toLowerCase() == ".epub" () => fileStore.req?.extension.toLowerCase() == ".epub"
); );
const isCsv = computed(() => fileStore.req?.extension.toLowerCase() == ".csv"); const isCsv = computed(
() =>
fileStore.req?.extension.toLowerCase() == ".csv" &&
fileStore.req.size <= CSV_MAX_SIZE
);
const isResizeEnabled = computed(() => resizePreview); const isResizeEnabled = computed(() => resizePreview);

View File

@@ -43,7 +43,7 @@ var resourceGetHandler = withUser(func(w http.ResponseWriter, r *http.Request, d
return renderJSON(w, r, file) return renderJSON(w, r, file)
} else if encoding == "true" { } else if encoding == "true" {
if file.Type != "text" { if file.Type != "text" {
return http.StatusUnsupportedMediaType, fmt.Errorf("file is not a text file") return renderJSON(w, r, file)
} }
f, err := d.user.Fs.Open(r.URL.Path) f, err := d.user.Fs.Open(r.URL.Path)