Merge branch 'dev' into translation
This commit is contained in:
commit
53316d73e5
4 changed files with 299 additions and 5 deletions
|
|
@ -5,13 +5,15 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { definePluginSettings } from "@api/Settings";
|
import { definePluginSettings } from "@api/Settings";
|
||||||
|
import { Button } from "@components/Button";
|
||||||
import { SettingsSection } from "@components/settings/tabs/plugins/components/Common";
|
import { SettingsSection } from "@components/settings/tabs/plugins/components/Common";
|
||||||
import { Switch } from "@components/Switch";
|
import { Switch } from "@components/Switch";
|
||||||
import { useForceUpdater } from "@utils/react";
|
import { useForceUpdater } from "@utils/react";
|
||||||
import { OptionType } from "@utils/types";
|
import { OptionType } from "@utils/types";
|
||||||
import { Select, TextInput } from "@webpack/common";
|
import { React, Select, showToast, TextArea, TextInput, Toasts } from "@webpack/common";
|
||||||
|
|
||||||
import { ServiceType } from "./types";
|
import { ServiceType } from "./types";
|
||||||
|
import { parseShareXConfig } from "./utils/sharex";
|
||||||
|
|
||||||
const serviceOptions = [
|
const serviceOptions = [
|
||||||
{ label: "Zipline", value: ServiceType.ZIPLINE, default: true },
|
{ label: "Zipline", value: ServiceType.ZIPLINE, default: true },
|
||||||
|
|
@ -20,7 +22,8 @@ const serviceOptions = [
|
||||||
{ label: "S3-Compatible", value: ServiceType.S3 },
|
{ label: "S3-Compatible", value: ServiceType.S3 },
|
||||||
{ label: "Catbox.moe", value: ServiceType.CATBOX },
|
{ label: "Catbox.moe", value: ServiceType.CATBOX },
|
||||||
...(IS_DISCORD_DESKTOP ? [{ label: "0x0.st", value: ServiceType.ZEROX0 }] : []),
|
...(IS_DISCORD_DESKTOP ? [{ label: "0x0.st", value: ServiceType.ZEROX0 }] : []),
|
||||||
{ label: "Litterbox", value: ServiceType.LITTERBOX }
|
{ label: "Litterbox", value: ServiceType.LITTERBOX },
|
||||||
|
{ label: "ShareX Custom Uploader", value: ServiceType.SHAREX }
|
||||||
];
|
];
|
||||||
|
|
||||||
const litterboxOptions = [
|
const litterboxOptions = [
|
||||||
|
|
@ -134,6 +137,12 @@ export const settings = definePluginSettings({
|
||||||
default: "",
|
default: "",
|
||||||
hidden: true
|
hidden: true
|
||||||
},
|
},
|
||||||
|
sharexConfig: {
|
||||||
|
type: OptionType.STRING,
|
||||||
|
description: "ShareX custom uploader JSON",
|
||||||
|
default: "",
|
||||||
|
hidden: true
|
||||||
|
},
|
||||||
stripQueryParams: {
|
stripQueryParams: {
|
||||||
type: OptionType.BOOLEAN,
|
type: OptionType.BOOLEAN,
|
||||||
description: "Strip query params from uploaded URLs",
|
description: "Strip query params from uploaded URLs",
|
||||||
|
|
@ -182,12 +191,49 @@ function SettingTextInput(props: {
|
||||||
export function SettingsComponent() {
|
export function SettingsComponent() {
|
||||||
const update = useForceUpdater();
|
const update = useForceUpdater();
|
||||||
const { store } = settings;
|
const { store } = settings;
|
||||||
|
const sharexFileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
const isNest = store.serviceType === ServiceType.NEST;
|
const isNest = store.serviceType === ServiceType.NEST;
|
||||||
const isEzHost = store.serviceType === ServiceType.EZHOST;
|
const isEzHost = store.serviceType === ServiceType.EZHOST;
|
||||||
const isS3 = store.serviceType === ServiceType.S3;
|
const isS3 = store.serviceType === ServiceType.S3;
|
||||||
const isZipline = store.serviceType === ServiceType.ZIPLINE;
|
const isZipline = store.serviceType === ServiceType.ZIPLINE;
|
||||||
const isCatbox = store.serviceType === ServiceType.CATBOX;
|
const isCatbox = store.serviceType === ServiceType.CATBOX;
|
||||||
const isLitterbox = store.serviceType === ServiceType.LITTERBOX;
|
const isLitterbox = store.serviceType === ServiceType.LITTERBOX;
|
||||||
|
const isShareX = store.serviceType === ServiceType.SHAREX;
|
||||||
|
|
||||||
|
const validateShareXConfig = () => {
|
||||||
|
try {
|
||||||
|
parseShareXConfig(store.sharexConfig || "");
|
||||||
|
showToast("ShareX config is valid", Toasts.Type.SUCCESS);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Invalid ShareX config";
|
||||||
|
showToast(message, Toasts.Type.FAILURE);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerShareXFileUpload = () => {
|
||||||
|
sharexFileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShareXFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e: ProgressEvent<FileReader>) => {
|
||||||
|
try {
|
||||||
|
const content = String(e.target?.result || "");
|
||||||
|
const parsed = parseShareXConfig(content);
|
||||||
|
store.sharexConfig = JSON.stringify(parsed, null, 2);
|
||||||
|
update();
|
||||||
|
showToast("Imported ShareX config", Toasts.Type.SUCCESS);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to import ShareX config";
|
||||||
|
showToast(message, Toasts.Type.FAILURE);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
event.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -342,6 +388,35 @@ export function SettingsComponent() {
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isShareX && (
|
||||||
|
<>
|
||||||
|
<SettingsSection
|
||||||
|
name="ShareX Custom Uploader Config"
|
||||||
|
description="Paste your ShareX custom uploader JSON (.sxcu/.json). DestinationType must include FileUploader or ImageUploader."
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
value={store.sharexConfig}
|
||||||
|
rows={10}
|
||||||
|
placeholder='{"RequestMethod":"POST","RequestURL":"https://example.com/api/upload","Body":"MultipartFormData"}'
|
||||||
|
onChange={v => store.sharexConfig = v}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
<SettingsSection name="ShareX Config Actions" description="Import from file or validate pasted config">
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<Button size="small" onClick={triggerShareXFileUpload}>Import .sxcu/.json</Button>
|
||||||
|
<Button size="small" onClick={validateShareXConfig}>Validate</Button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={sharexFileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".sxcu,.json,application/json,text/plain"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleShareXFileUpload}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<SettingsSection tag="label" name="Strip Query Parameters" description="Strip query parameters from the uploaded file URL" inlineSetting>
|
<SettingsSection tag="label" name="Strip Query Parameters" description="Strip query parameters from the uploaded file URL" inlineSetting>
|
||||||
<Switch
|
<Switch
|
||||||
checked={store.stripQueryParams}
|
checked={store.stripQueryParams}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ export enum ServiceType {
|
||||||
S3 = "s3",
|
S3 = "s3",
|
||||||
CATBOX = "catbox",
|
CATBOX = "catbox",
|
||||||
ZEROX0 = "0x0",
|
ZEROX0 = "0x0",
|
||||||
LITTERBOX = "litterbox"
|
LITTERBOX = "litterbox",
|
||||||
|
SHAREX = "sharex"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const serviceLabels: Record<ServiceType, string> = {
|
export const serviceLabels: Record<ServiceType, string> = {
|
||||||
|
|
@ -21,7 +22,8 @@ export const serviceLabels: Record<ServiceType, string> = {
|
||||||
[ServiceType.S3]: "S3-Compatible",
|
[ServiceType.S3]: "S3-Compatible",
|
||||||
[ServiceType.CATBOX]: "Catbox",
|
[ServiceType.CATBOX]: "Catbox",
|
||||||
[ServiceType.ZEROX0]: "0x0.st",
|
[ServiceType.ZEROX0]: "0x0.st",
|
||||||
[ServiceType.LITTERBOX]: "Litterbox"
|
[ServiceType.LITTERBOX]: "Litterbox",
|
||||||
|
[ServiceType.SHAREX]: "ShareX Custom Uploader"
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface UploadResponse {
|
export interface UploadResponse {
|
||||||
|
|
@ -41,3 +43,17 @@ export interface NativeUploadResult {
|
||||||
url?: string;
|
url?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShareXUploaderConfig {
|
||||||
|
Version?: string;
|
||||||
|
Name?: string;
|
||||||
|
DestinationType?: string;
|
||||||
|
RequestMethod?: string;
|
||||||
|
RequestURL?: string;
|
||||||
|
Headers?: Record<string, string | number | boolean>;
|
||||||
|
Body?: string;
|
||||||
|
FileFormName?: string;
|
||||||
|
Arguments?: Record<string, string | number | boolean>;
|
||||||
|
URL?: string;
|
||||||
|
ErrorMessage?: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
92
src/equicordplugins/fileUpload/utils/sharex.ts
Normal file
92
src/equicordplugins/fileUpload/utils/sharex.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2025 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ShareXUploaderConfig } from "../types";
|
||||||
|
|
||||||
|
const JSON_TEMPLATE_REGEX = /\$json:([^$]+)\$|\{json:([^}]+)\}/g;
|
||||||
|
const RESPONSE_TEMPLATE_REGEX = /\$response\$|\{response\}/g;
|
||||||
|
|
||||||
|
function parseDestinationTypes(destinationType?: string): string[] {
|
||||||
|
if (!destinationType) return [];
|
||||||
|
|
||||||
|
return destinationType
|
||||||
|
.split(",")
|
||||||
|
.map(type => type.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNestedValue(data: unknown, path: string): unknown {
|
||||||
|
const normalizedPath = path.replace(/\[(\d+)\]/g, ".$1");
|
||||||
|
const segments = normalizedPath.split(".").filter(Boolean);
|
||||||
|
|
||||||
|
let current: unknown = data;
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (!current || typeof current !== "object" || !(segment in current)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = (current as Record<string, unknown>)[segment];
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseShareXConfig(configText: string): ShareXUploaderConfig {
|
||||||
|
let parsed: unknown;
|
||||||
|
const normalizedConfigText = configText.replace(/^\uFEFF/, "").trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(normalizedConfigText);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid ShareX JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
throw new Error("ShareX config must be a JSON object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = parsed as ShareXUploaderConfig;
|
||||||
|
|
||||||
|
if (!config.RequestURL || typeof config.RequestURL !== "string") {
|
||||||
|
throw new Error("ShareX RequestURL is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSupportedShareXDestination(config)) {
|
||||||
|
throw new Error("ShareX DestinationType must include FileUploader or ImageUploader");
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSupportedShareXDestination(config: ShareXUploaderConfig): boolean {
|
||||||
|
const destinationTypes = parseDestinationTypes(config.DestinationType);
|
||||||
|
|
||||||
|
if (destinationTypes.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return destinationTypes.includes("FileUploader") || destinationTypes.includes("ImageUploader");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveShareXTemplate(
|
||||||
|
template: string | undefined,
|
||||||
|
responseText: string,
|
||||||
|
responseJson: unknown
|
||||||
|
): string | undefined {
|
||||||
|
if (!template) return undefined;
|
||||||
|
|
||||||
|
let output = template.trim();
|
||||||
|
if (!output) return undefined;
|
||||||
|
|
||||||
|
output = output.replace(RESPONSE_TEMPLATE_REGEX, responseText);
|
||||||
|
output = output.replace(JSON_TEMPLATE_REGEX, (_, pathA: string, pathB: string) => {
|
||||||
|
const path = pathA || pathB;
|
||||||
|
const value = getNestedValue(responseJson, path);
|
||||||
|
return value == null ? "" : String(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { settings } from "@equicordplugins/fileUpload/settings";
|
import { settings } from "@equicordplugins/fileUpload/settings";
|
||||||
import { ServiceType, UploadResponse } from "@equicordplugins/fileUpload/types";
|
import { ServiceType, ShareXUploaderConfig, UploadResponse } from "@equicordplugins/fileUpload/types";
|
||||||
import { copyToClipboard } from "@utils/clipboard";
|
import { copyToClipboard } from "@utils/clipboard";
|
||||||
import { PluginNative } from "@utils/types";
|
import { PluginNative } from "@utils/types";
|
||||||
import { showToast, Toasts } from "@webpack/common";
|
import { showToast, Toasts } from "@webpack/common";
|
||||||
|
|
@ -13,6 +13,7 @@ import { showToast, Toasts } from "@webpack/common";
|
||||||
import { convertApngToGif } from "./apngToGif";
|
import { convertApngToGif } from "./apngToGif";
|
||||||
import { getExtensionFromBytes, getExtensionFromMime, getMimeFromExtension, getUrlExtension } from "./getMediaUrl";
|
import { getExtensionFromBytes, getExtensionFromMime, getMimeFromExtension, getUrlExtension } from "./getMediaUrl";
|
||||||
import { isS3Configured, uploadToS3 } from "./s3";
|
import { isS3Configured, uploadToS3 } from "./s3";
|
||||||
|
import { parseShareXConfig, resolveShareXTemplate } from "./sharex";
|
||||||
|
|
||||||
const Native = IS_DISCORD_DESKTOP
|
const Native = IS_DISCORD_DESKTOP
|
||||||
? VencordNative.pluginHelpers.FileUpload as PluginNative<typeof import("../native")>
|
? VencordNative.pluginHelpers.FileUpload as PluginNative<typeof import("../native")>
|
||||||
|
|
@ -22,6 +23,106 @@ const CORS_PROXY = "https://cors.keiran0.workers.dev"; // im hosting this on clo
|
||||||
|
|
||||||
let isUploading = false;
|
let isUploading = false;
|
||||||
|
|
||||||
|
function resolveShareXRequestValue(value: string | number | boolean, filename: string): string {
|
||||||
|
return String(value)
|
||||||
|
.replace(/\$filename\$/g, filename)
|
||||||
|
.replace(/\{filename\}/g, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseShareXConfigFromSettings(): ShareXUploaderConfig {
|
||||||
|
const configText = settings.store.sharexConfig || "";
|
||||||
|
if (!configText.trim()) {
|
||||||
|
throw new Error("ShareX config is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseShareXConfig(configText);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadToShareX(fileBlob: Blob, filename: string): Promise<string> {
|
||||||
|
const config = parseShareXConfigFromSettings();
|
||||||
|
const method = (config.RequestMethod || "POST").toUpperCase();
|
||||||
|
const requestUrl = config.RequestURL!.trim();
|
||||||
|
const bodyType = (config.Body || "MultipartFormData").toLowerCase();
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
for (const [key, value] of Object.entries(config.Headers || {})) {
|
||||||
|
headers.set(key, resolveShareXRequestValue(value, filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildArguments = () => {
|
||||||
|
const args: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(config.Arguments || {})) {
|
||||||
|
args[key] = resolveShareXRequestValue(value, filename);
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
};
|
||||||
|
|
||||||
|
let body: BodyInit;
|
||||||
|
|
||||||
|
if (bodyType === "multipartformdata" || bodyType === "formdata") {
|
||||||
|
headers.delete("content-type");
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
const fileField = config.FileFormName || "file";
|
||||||
|
formData.append(fileField, fileBlob, filename);
|
||||||
|
|
||||||
|
const args = buildArguments();
|
||||||
|
for (const [key, value] of Object.entries(args)) {
|
||||||
|
formData.append(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
body = formData;
|
||||||
|
} else if (bodyType === "binary") {
|
||||||
|
body = fileBlob;
|
||||||
|
} else if (bodyType === "json") {
|
||||||
|
if (!headers.has("content-type")) {
|
||||||
|
headers.set("content-type", "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildArguments();
|
||||||
|
body = JSON.stringify(payload);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported ShareX Body type: ${config.Body || "unknown"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(requestUrl, { method, headers, body });
|
||||||
|
} catch (error) {
|
||||||
|
if (Native) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxiedUrl = `${CORS_PROXY}?url=${encodeURIComponent(requestUrl)}`;
|
||||||
|
response = await fetch(proxiedUrl, { method, headers, body });
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
let responseJson: unknown = null;
|
||||||
|
try {
|
||||||
|
responseJson = responseText ? JSON.parse(responseText) : null;
|
||||||
|
} catch {
|
||||||
|
responseJson = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const configuredError = resolveShareXTemplate(config.ErrorMessage, responseText, responseJson);
|
||||||
|
throw new Error(configuredError || `Upload failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredUrl = resolveShareXTemplate(config.URL, responseText, responseJson)?.trim();
|
||||||
|
const fallbackUrl = typeof responseJson === "object" && responseJson && "url" in responseJson
|
||||||
|
? String((responseJson as Record<string, unknown>).url || "")
|
||||||
|
: responseText.trim();
|
||||||
|
|
||||||
|
const resultUrl = configuredUrl || fallbackUrl;
|
||||||
|
if (!resultUrl) {
|
||||||
|
throw new Error("No URL returned from ShareX uploader");
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultUrl;
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadToZipline(fileBlob: Blob, filename: string): Promise<string> {
|
async function uploadToZipline(fileBlob: Blob, filename: string): Promise<string> {
|
||||||
const { serviceUrl, ziplineToken, folderId } = settings.store;
|
const { serviceUrl, ziplineToken, folderId } = settings.store;
|
||||||
|
|
||||||
|
|
@ -141,6 +242,13 @@ export function isConfigured(): boolean {
|
||||||
return Boolean(Native);
|
return Boolean(Native);
|
||||||
case ServiceType.LITTERBOX:
|
case ServiceType.LITTERBOX:
|
||||||
return true;
|
return true;
|
||||||
|
case ServiceType.SHAREX:
|
||||||
|
try {
|
||||||
|
parseShareXConfigFromSettings();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
case ServiceType.ZIPLINE:
|
case ServiceType.ZIPLINE:
|
||||||
default:
|
default:
|
||||||
return Boolean(serviceUrl && ziplineToken);
|
return Boolean(serviceUrl && ziplineToken);
|
||||||
|
|
@ -348,6 +456,9 @@ export async function uploadFile(url: string): Promise<void> {
|
||||||
case ServiceType.LITTERBOX:
|
case ServiceType.LITTERBOX:
|
||||||
uploadedUrl = await uploadToLitterbox(typedBlob, filename);
|
uploadedUrl = await uploadToLitterbox(typedBlob, filename);
|
||||||
break;
|
break;
|
||||||
|
case ServiceType.SHAREX:
|
||||||
|
uploadedUrl = await uploadToShareX(typedBlob, filename);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error("Unknown service type");
|
throw new Error("Unknown service type");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue