Compare commits

..
Author SHA1 Message Date
gtsteffaniak 56c235978d chore(deps): bump go-media to v1.3.0
Fixes the two critical CodeRabbit comments (media.HEIFTransform / media.KeyframeTimes undefined on v1.2.2) and the resulting CI failures in test-backend, lint-backend, check-docs and both docker builds.

Also rebased onto dev/v2.2.0 (49ba282, #3364): kept base's tagsCache alongside the shared mediacache for probes/durations/subtitles.
2026-11-13 14:27:29 -06:00
Graham Steffaniak 4c3850c1de fix(media): check the trailing keyframe gap before trusting go-media; robust mtime test
- gomediaKeyframes now also treats the stretch from the last go-media
  keyframe to the end of the media as a gap. The planner ends the final
  segment at the media end, so a partial index (for example Matroska Cues
  that stop early) could force a full video transcode without ffprobe
  being asked. When that trailing gap is past the copy cap in pre-flight,
  ffprobe now decides. The duration and start time come from the stream
  info the engine already has, normalized the same way as the planner.
- mediacache: the 1 ns mtime case is tested through FromFileInfo with a
  stub FileInfo; the on-disk Stat check uses a 1 s mtime step, so the
  test passes on filesystems with coarse timestamps.
2026-11-13 14:23:10 -06:00
Graham Steffaniak 514b06f623 feat(media): offload keyframe index, HEIC rotation and more previews to go-media; share one media cache
- HLS keyframe index: gomedia.KeyframeTimes / KeyframeTimesWithin read the
  first video stream's keyframes from the MP4/MOV/M4V stss/stts/ctts tables
  or Matroska/WebM Cues/block headers (go-media media.KeyframeTimes), with
  go-ffmpeg's 1 ms dedupe and a dev-mode shadow diff against ffprobe.
  probeKeyframesCached tries go-media first and falls back to the existing
  ffprobe pre-flight. A go-media timeline with a gap past the copy cap is
  confirmed by ffprobe before transcoding is forced, because go-media can
  only miss keyframes (parser-flagged IDRs, partial Cues), never add them.
- HEIC/AVIF rotation: ffmpeg.CorrectHEICJPEGOrientation gets the display
  rotation from go-media's HEIFTransform (installed by gomedia as
  ffmpeg.SetHEICDisplayRotationReader). The "ffmpeg -f null" stderr probe
  only runs when go-media is off or cannot read the item properties.
- Containers: AVIF routes through the HEIC preview path and heifImageExts.
  ASF/WMV thumbnails go to go-media (WMV3); WMV1/WMV2 fall back to ffmpeg.
  AVI, TS/M2TS/MTS and MPEG-PS/VOB stay on ffmpeg (go-media's probe does
  not match ffprobe yet; documented in videoContainerExts). Stale comments
  in gomedia.go and preview/video.go are fixed.
- Shared cache: new internal/mediacache replaces ffmpeg's MediaCache,
  SubtitleContentCache and MetadataCache and gomedia's probe and subtitle
  caches. Probe, duration, subtitle list and subtitle content are keyed by
  path, size and mtime (ns) and shared by both backends. ffmpeg ProbeFile
  results are now cached too. Shadow runs use mediacache.WithoutCache so
  they compare fresh ffmpeg output; toggling "Always use ffmpeg" clears it.
2026-11-13 14:23:10 -06:00
248 changed files with 5197 additions and 5216 deletions

No files matched your search

+1
View File
@@ -30,6 +30,7 @@
"extensions": [
"golang.go",
"Vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker"
]
+1 -1
View File
@@ -1 +1 @@
22.22.2
22
+134 -258
View File
@@ -1,264 +1,140 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true,
"root": "../"
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"files": {
"ignoreUnknown": true,
"includes": [
"**",
"!dist",
"!public/config.generated.yaml",
"!public/index.html",
"!node_modules",
"!test-results"
]
},
"formatter": {
"enabled": false
},
"files": {
"ignoreUnknown": true,
"includes": [
"**",
"!dist",
"!public/config.generated.yaml",
"!public/index.html",
"!node_modules",
"!test-results"
]
},
"formatter": { "enabled": false },
"linter": {
"enabled": true,
"rules": {
"a11y": {
"noAutofocus": "off",
"noNoninteractiveElementToInteractiveRole": "off",
"noStaticElementInteractions": "off",
"noSvgWithoutTitle": "off",
"useAriaPropsSupportedByRole": "off",
"useGenericFontNames": "off",
"useKeyWithClickEvents": "off",
"useMediaCaption": "off",
"useSemanticElements": "off",
"useValidAnchor": "off"
},
"complexity": {
"noForEach": "on",
"noImportantStyles": "off",
"noRedundantDefaultExport": "warn",
"noUnnecessaryTemplateExpression": "error",
"noUselessStringConcat": "warn",
"useMathMinMax": "warn"
},
"correctness": {
"noUnknownPseudoClass": "off",
"noVueImportCompilerMacros": "error",
"noVueRefAsOperand": "error",
"noVueVOnNumberValues": "error",
"useVueValidVFor": "error",
"noVueVIfWithVFor": "error"
},
"nursery": {
"noFloatingPromises": "error",
"noImpliedEval": "error",
"noJsonUnsafeValues": "error",
"noMisusedPromises": "error",
"noVueDeprecatedScopedSlots": "error",
"noVueUndeclaredDirectives": "off",
"useConsistentObjectKeys": "error",
"useVueBaseImport": "warn",
"noDoubleComparison": "on",
"noNestedSwitch": "on",
"noVueLifecycleAfterAwait": "error",
"noVueShadowNativeEvents": "warn",
"noVueVHtml": "error",
"noVueVText": "warn",
"noVueVTextVHtmlOnComponent": "error",
"useSimplifiedBooleanReturn": "warn",
"useVueConsistentEventHyphenation": "warn",
"noPromiseExecutorReturn": "warn",
"noArgumentsCallerOrCallee": "warn",
"noVueRequiredPropWithDefault": "warn",
"noBooleanSortComparator": "error",
"noObsoleteAttributes": "error",
"noUnsafeReturn": "warn",
"noUselessSplice": "warn",
"noVueWatchAfterAwait": "error",
"useMathAbs": "warn",
"useMathTrunc": "warn",
"useVueComponentIs": "error",
"useVueTypedRef": "error",
"noReturnArrayPush": "warn",
"noUselessRecursion": "error",
"noVueComputedPropertiesInData": "error",
"noVueDeprecatedDeleteSet": "error",
"noVueDeprecatedRouterLinkTagProp": "error",
"noVueMutatingProps": "error",
"noVueMultipleSlotArgs": "warn",
"noVueSideEffectsInComputed": "error",
"noVueUnusedRefs": "error",
"useVueTemplateRef": "warn",
"useVueTypedProps": "warn",
"useVueVModel": "warn",
"useVueValidDefineEmits": "error",
"useVueValidDefineProps": "error",
"useVueValidVIs": "error",
"useVueValidVModel": "error",
"useVueValidVShow": "error",
"useVueValidVSlot": "error",
"noVueDeprecatedListeners": "error",
"noArrayForEach": "error",
"noChainedComparison": "error",
"noDynamicDelete": "warn",
"noLateEventControl": "error",
"noObjectLiteralComparison": "error",
"noPlaywrightUnsafeReferences": "error",
"noUnnecessaryStringTrim": "warn",
"noUselessLengthCheck": "info",
"noUselessTypeAssertion": "warn",
"noVueChildContent": "error",
"noVueDeprecatedConfigKeyCodes": "info",
"noVueDeprecatedEventsApi": "error",
"noVueDeprecatedHtmlElementIs": "error",
"noVueDeprecatedModelDefinition": "error",
"noVueDeprecatedPropsDefaultThis": "error",
"noVueDeprecatedVBindSync": "error",
"noVueLoneTemplate": "error",
"noVueMixin": "error",
"noVueMultipleObjectsInClass": "warn",
"noVueRefObjectReactivityLoss": "error",
"noVueRestrictedCallAfterAwait": "error",
"noVueRestrictedVOn": "warn",
"noVueTemplateKey": "error",
"noVueTemplateShadow": "warn",
"noVueThisInTemplate": "warn",
"noVueUndeclaredEmits": "warn",
"noVueUndeclaredProperties": "error",
"noVueUndeclaredSlots": "warn",
"noVueUselessVBind": "warn",
"noVueUselessTemplateAttributes": "error",
"noVueVForTemplateKeyOnChild": "error",
"useObjectFromEntries": "warn",
"useVueBlockOrder": {
"level": "on",
"options": {
"order": [
"template",
"script",
"style"
]
}
},
"useVueBooleanPropTypeFirst": "warn",
"useVueComputedPropertyReturn": "error",
"useVueEmitValidator": "warn",
"useVueEmitsValidatorReturn": "error",
"useVuePropTypeConstructor": "error",
"useVueRenderReturn": "error",
"useVueSingleEventPayload": "warn",
"useVueTypedObjectProps": "warn",
"useVueValidDefaultProp": "error",
"useVueValidNextTick": "error",
"useVueValidDefineOptions": "error",
"noRedundantTypeConstituents": "warn",
"noUselessSpread": "warn",
"noVueExposeAfterAwait": "error"
},
"performance": { "useVueVapor": "off" },
"security": {
"preset": "recommended",
"useIframeSandbox": "off"
},
"style": {
"noDescendingSpecificity": "off",
"noTopLevelLiterals": "error",
"noVueOptionsApi": "off",
"useVueConsistentDefinePropsDeclaration": "on",
"useVueConsistentVBindStyle": "off",
"useVueHyphenatedAttributes": "off",
"useVueMultiWordComponentNames": "off",
"useVueNextTickPromise": "warn",
"useImportsFirst": "off",
"useVarsOnTop": "on",
"useDomQuerySelector": "warn",
"useVueDefineMacrosOrder": "warn"
},
"suspicious": {
"noEmptyObjectKeys": "error",
"noExplicitAny": "warn",
"noImportCycles": "off",
"noNestedPromises": "error",
"noParametersOnlyUsedInRecursion": "error",
"noShadow": "off",
"noUnnecessaryConditions": "off",
"useArraySortCompare": "error",
"useScopedStyles": "off",
"noConfusingVoidType": "warn"
}
},
"domains": { "vue": "all", "project": "all", "types": "all" }
},
"css": {
"formatter": { "enabled": false },
"linter": { "enabled": false },
"assist": { "enabled": false }
},
"html": {
"experimentalFullSupportEnabled": true,
"parser": { "interpolation": true },
"linter": { "enabled": true }
},
"overrides": [
{
"includes": ["tests/playwright/**"],
"linter": {
"rules": {
"complexity": { "noPlaywrightUselessAwait": "error" },
"correctness": {
"noEmptyPattern": "off",
"noPlaywrightMissingAwait": "error",
"noUnresolvedImports": "off",
"usePlaywrightValidDescribeCallback": "error"
},
"style": {
"noPlaywrightElementHandle": "error",
"noPlaywrightEval": "error",
"noPlaywrightWaitForSelector": "error"
},
"suspicious": {
"noPlaywrightForceOption": "error",
"noPlaywrightNetworkidle": "error",
"noPlaywrightPagePause": "error",
"noPlaywrightWaitForNavigation": "error",
"noPlaywrightWaitForTimeout": "warn",
"noEmptyObjectKeys": "error"
}
}
}
},
{
"includes": ["**/*.js"],
"linter": {
"rules": {
"nursery": { "noUnsafeReturn": "off" }
}
}
},
{
"includes": [
"scripts/postcss-strip-legacy.ts",
"playwright.config.ts",
"playwright.performance.config.ts",
"**/*.test.js",
"**/*.test.ts"
],
"linter": {
"rules": {
"correctness": {
"noUndeclaredDependencies": "off",
"noUnresolvedImports": "off"
}
}
}
}
],
"assist": {
"actions": {
"source": {
"organizeImports": "off",
"useGraphqlSortedDefinitions": "on"
"enabled": true,
"actions": { "source": { "organizeImports": "off" } }
},
"css": {
"linter": { "enabled": false },
"formatter": { "enabled": false },
"assist": { "enabled": false }
},
"html": {
"experimentalFullSupportEnabled": true,
"parser": { "interpolation": true },
"linter": { "enabled": true }
},
"linter": {
"enabled": true,
"domains": { "vue": "all", "project": "all", "types": "all" },
"rules": {
"performance": {
"useVueVapor": "off"
},
"security": {
"preset": "recommended"
},
"a11y": {
"noAutofocus": "off",
"noNoninteractiveElementToInteractiveRole": "off",
"noStaticElementInteractions": "off",
"noSvgWithoutTitle": "off",
"useAriaPropsSupportedByRole": "off",
"useGenericFontNames": "off",
"useKeyWithClickEvents": "off",
"useMediaCaption": "off",
"useSemanticElements": "off",
"useValidAnchor": "off"
},
"complexity": {
"noForEach": "on",
"noImportantStyles": "off",
"noRedundantDefaultExport": "warn"
},
"correctness": {
"noUnknownPseudoClass": "off"
},
"nursery": {
"noEmptyObjectKeys": "error",
"noFloatingPromises": "error",
"noImpliedEval": "error",
"noJsonUnsafeValues": "error",
"noMisusedPromises": "error",
"noTopLevelLiterals": "error",
"noVueDeprecatedScopedSlots": "error",
"noVueImportCompilerMacros": "error",
"noVueRefAsOperand": "error",
"noVueUndeclaredDirectives": "off",
"noVueVOnNumberValues": "error",
"useConsistentObjectKeys": "error",
"useVueBaseImport": "warn",
"useVueConsistentDefinePropsDeclaration": "off",
"useVueNextTickPromise": "warn",
"useVueValidVFor": "error"
},
"style": {
"noDescendingSpecificity": "off",
"noVueOptionsApi": "off",
"useVueConsistentVBindStyle": "off",
"useVueHyphenatedAttributes": "off",
"useVueMultiWordComponentNames": "off"
},
"suspicious": {
"noExplicitAny": "warn",
"noImportCycles": "off",
"noNestedPromises": "error",
"noParametersOnlyUsedInRecursion": "error",
"noShadow": "off",
"noUnnecessaryConditions": "off",
"useArraySortCompare": "error"
}
}
}
},
"overrides": [
{
"includes": ["tests/playwright/**"],
"linter": {
"rules": {
"correctness": {
"noEmptyPattern": "off",
"noUnresolvedImports": "off"
},
"nursery": {
"noPlaywrightElementHandle": "error",
"noPlaywrightEval": "error",
"noPlaywrightForceOption": "error",
"noPlaywrightMissingAwait": "error",
"noPlaywrightNetworkidle": "error",
"noPlaywrightPagePause": "error",
"noPlaywrightUselessAwait": "error",
"noPlaywrightWaitForNavigation": "error",
"noPlaywrightWaitForSelector": "error",
"noPlaywrightWaitForTimeout": "warn",
"usePlaywrightValidDescribeCallback": "error"
}
}
}
},
{
"includes": [
"scripts/postcss-strip-legacy.ts",
"playwright.config.ts",
"playwright.performance.config.ts",
"**/*.test.js",
"**/*.test.ts"
],
"linter": {
"rules": {
"correctness": {
"noUnresolvedImports": "off",
"noUndeclaredDependencies": "off"
}
}
}
}
]
}
+70
View File
@@ -0,0 +1,70 @@
import { defineConfig } from "eslint/config";
import * as tsParser from "@typescript-eslint/parser";
import pluginVue from "eslint-plugin-vue";
import vueI18n from "@intlify/eslint-plugin-vue-i18n";
export default defineConfig(
{ ignores: ["**/dist/**", "tests/playwright-files/**"] },
...pluginVue.configs["flat/essential"],
...vueI18n.configs.recommended,
// js/ts is parsed for vue-i18n to check t() calls
{ files: ["**/*.js", "**/*.ts"], languageOptions: { parser: tsParser } },
{ files: ["**/*.vue"], languageOptions: { parserOptions: { parser: tsParser } } },
{
settings: {
"vue-i18n": {
localeDir: "src/i18n/en.json",
messageSyntaxVersion: "^11.0.0",
},
},
rules: {
"@intlify/vue-i18n/no-missing-keys": "error",
"@intlify/vue-i18n/no-unused-keys": ["error", {
src: "./src",
extensions: [".js", ".vue", ".ts"],
ignores: ["/^languages\\./"],
}],
"@intlify/vue-i18n/no-raw-text": ["error", {
ignoreNodes: ["i", "v-icon"],
}],
"@intlify/vue-i18n/no-missing-keys-in-other-locales": "warn",
},
},
{
files: ["**/*.vue"],
rules: {
// this are the most common ones that likely biome also has
// we can add more to the list or if some if buggy in biome better enable it here.
"vue/no-duplicate-attributes": "off",
"vue/no-use-v-if-with-v-for": "off",
"vue/require-v-for-key": "off",
"vue/valid-template-root": "off",
"vue/valid-v-bind": "off",
"vue/valid-v-cloak": "off",
"vue/valid-v-else": "off",
"vue/valid-v-else-if": "off",
"vue/valid-v-for": "off",
"vue/valid-v-html": "off",
"vue/valid-v-if": "off",
"vue/valid-v-on": "off",
"vue/valid-v-once": "off",
"vue/valid-v-pre": "off",
"vue/valid-v-text": "off",
"vue/no-deprecated-v-on-number-modifiers": "off",
"vue/no-dupe-keys": "off",
"vue/no-reserved-keys": "off",
"vue/no-reserved-props": "off",
"vue/no-deprecated-data-object-declaration": "off",
"vue/no-arrow-functions-in-watch": "off",
"vue/no-ref-as-operand": "off",
"vue/prefer-import-from-vue": "off",
"vue/multi-word-component-names": "off",
"vue/no-reserved-component-names": "off",
"vue/no-parsing-error": "off",
"vue/no-unused-components": "warn",
"vue/no-v-text-v-html-on-component": "warn",
},
},
);
+3090 -931
View File
File diff suppressed because it is too large. Load diff
+43 -37
View File
@@ -4,8 +4,8 @@
"private": true,
"type": "module",
"engines": {
"npm": ">=12.0.0",
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
"npm": ">=9.0.0",
"node": "^22.13.0 || ^24.0.0 || >=26.0.0"
},
"scripts": {
"build": "vite build",
@@ -13,71 +13,77 @@
"dev": "vite",
"watch": "vite build --watch",
"typecheck": "vue-tsc --build",
"lint": "biome check && npm run i18n:check",
"lint:fix": "biome check --write",
"lint": "biome check && eslint src/",
"lint:fix": "biome check --write && eslint --fix src/",
"lint:biome": "biome check",
"lint:biome:fix": "biome check --write",
"lint:eslint": "eslint src/",
"format": "biome format --write",
"i18n:check": "node scripts/i18n/index.ts check",
"i18n:check:strict": "node scripts/i18n/index.ts check --strict",
"lint:css": "stylelint \"**/*.{css,vue}\"",
"lint:css:fix": "stylelint --fix \"**/*.{css,vue}\"",
"i18n:types": "node scripts/i18n/index.ts types",
"i18n:sync": "node scripts/i18n/index.ts sync",
"i18n:cleanup": "node scripts/i18n/index.ts sync --cleanup",
"i18n:enforce-order": "node scripts/i18n/index.ts sync --enforce-order",
"i18n:sync": "node ./scripts/sync-translations.js",
"i18n:check": "node ./scripts/sync-translations.js --check",
"i18n:enforce-order": "node scripts/sync-translations.js --enforce-order",
"i18n:cleanup": "node scripts/sync-translations.js --cleanup",
"icons:sync": "node ./scripts/build-icon-subset.js",
"icons:check": "node ./scripts/build-icon-subset.js --check",
"test": "vitest run "
},
"dependencies": {
"@likecoin/epub-ts": "^0.7.3",
"@likecoin/epub-ts": "^0.7.2",
"@onlyoffice/document-editor-vue": "^1.7.0",
"ace-builds": "^1.45.2",
"chart.js": "^4.6.1",
"ace-builds": "^1.45.0",
"chart.js": "^4.5.1",
"dompurify": "^3.4.17",
"highlight.js": "^11.12.0",
"hls.js": "^1.7.4",
"hls.js": "^1.7.2",
"js-yaml": "^5.5.0",
"mammoth": "^1.13.0",
"marked": "^18.2.0",
"marked": "^18.1.1",
"plyr": "^3.8.5",
"qrcode.vue": "^3.11.0",
"temml": "^0.14.0",
"three": "^0.187.0",
"vue": "^3.5.45",
"vue-i18n": "^11.4.19",
"vue-router": "^5.5.0"
"three": "^0.186.1",
"vue": "^3.5.44",
"vue-i18n": "^11.4.16",
"vue-router": "^5.4.0"
},
"overrides": {
"minimatch": "^11.0.8",
"minimatch": "^10.2.6",
"@xmldom/xmldom": "^0.8.15",
"mammoth": {
"argparse": "^3.0.4"
"argparse": "^2.0.1"
},
"@intlify/vue-i18n-extensions": {
"vue-i18n": "$vue-i18n"
},
"glob": "^14.0.6"
"@intlify/eslint-plugin-vue-i18n": {
"glob": "^13.0.0"
}
},
"devDependencies": {
"@biomejs/biome": "2.6.7",
"@intlify/unplugin-vue-i18n": "^11.2.8",
"@playwright/test": "^1.65.0",
"@types/node": "^26.6.6",
"@vitejs/plugin-vue": "^6.0.11",
"deepl-node": "^1.29.0",
"esbuild": "^0.28.2",
"@biomejs/biome": "2.5.15",
"@intlify/eslint-plugin-vue-i18n": "^4.5.1",
"@intlify/unplugin-vue-i18n": "^11.2.7",
"@playwright/test": "^1.64.1",
"@types/node": "^26.6.4",
"@typescript-eslint/parser": "^8.72.0",
"@vitejs/plugin-vue": "^6.0.10",
"deepl-node": "^1.28.1",
"eslint": "^10.13.0",
"eslint-plugin-vue": "^10.11.1",
"fs-extra": "^11.4.1",
"jsdom": "^31.0.1",
"stylelint": "^18.0.0",
"stylelint-config-standard": "^41.0.0",
"jsdom": "^30.1.3",
"stylelint": "^17.16.0",
"stylelint-config-standard": "^40.0.0",
"stylelint-config-standard-vue": "^2.0.0",
"typescript": "^6.0.3",
"vite": "^8.3.8",
"vite-plugin-checker": "^1.0.0",
"vite-plugin-compression2": "^2.5.4",
"vitest": "^5.1.0",
"vue-tsc": "^3.3.14",
"vite": "^8.3.4",
"vite-plugin-checker": "^0.14.5",
"vite-plugin-compression2": "^2.5.3",
"vitest": "^5.0.5",
"vue-eslint-parser": "^10.4.1",
"vue-tsc": "^3.3.13",
"wawoff2": "^2.0.1"
}
}
+2 -2
View File
@@ -142,7 +142,7 @@ function scanMimetype(icons) {
}
function scanConstants(icons) {
const content = fs.readFileSync(path.join(srcDir, 'utils/constants.ts'), 'utf8');
const content = fs.readFileSync(path.join(srcDir, 'utils/constants.js'), 'utf8');
const iconField = new RegExp(`\\bicon:\\s*["'](${ICON_NAME})["']`, 'g');
for (const match of content.matchAll(iconField)) addIcon(icons, match[1]);
}
@@ -185,7 +185,7 @@ function collectIcons() {
scanSearchFilters(icons);
scanMarkdownViewer(icons);
icons.delete('close_back');
return [...icons].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
return [...icons].sort();
}
function readInventory() {
@@ -1,246 +0,0 @@
/** biome-ignore-all lint/suspicious/noTemplateCurlyInString: the tests need it */
import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { maskJs, maskSource } from "../shared.ts";
import { syncTranslations } from "../sync.ts";
import { literalCalls } from "../types.ts";
// The sync reads and writes the locales folder: point it at a temporary one.
const dir = await vi.hoisted(async () => {
const { mkdtempSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
return mkdtempSync(join(tmpdir(), "i18n-test-"));
});
vi.mock("../shared.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("../shared.ts")>();
const { readdirSync } = await import("node:fs");
return {
...actual,
localesDir: dir,
translationFiles: () => readdirSync(dir).filter((file) => file.endsWith(".json") && file !== "en.json").sort(),
};
});
vi.mock("deepl-node", () => ({
DeepLClient: class {
async translateText(text: string) {
return { text: `[${text}]` };
}
},
}));
const write = (file: string, content: unknown) => fs.writeFileSync(path.join(dir, file), JSON.stringify(content));
const read = (file: string): unknown => JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
describe("sync with a different shape in a locale", () => {
beforeEach(() => {
process.env.DEEPL_API_KEY = "test-key";
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
for (const file of fs.readdirSync(dir)) fs.rmSync(path.join(dir, file));
});
const run = (options: { enforceOrder?: boolean; cleanup?: boolean } = {}) =>
syncTranslations({ enforceOrder: false, cleanup: false, ...options });
describe("an object where en.json has a string", () => {
beforeEach(() => {
write("en.json", { general: { title: "Title" } });
write("de.json", { general: { title: { old: "Alt" } } });
});
it("replaces the subtree with a translation", async () => {
expect(await run()).toBe(0);
expect(read("de.json")).toEqual({ general: { title: "[Title]" } });
});
it("removes the obsolete subtree on cleanup", async () => {
expect(await run({ cleanup: true })).toBe(0);
expect(read("de.json")).toEqual({ general: {} });
});
it("removes the obsolete subtree when enforcing the order", async () => {
expect(await run({ enforceOrder: true })).toBe(0);
expect(read("de.json")).toEqual({ general: {} });
});
});
describe("a string where en.json has an object", () => {
beforeEach(() => {
write("en.json", { group: { a: "A" } });
write("de.json", { group: "legacy" });
});
it("replaces the string with the translated keys", async () => {
await run();
expect(read("de.json")).toEqual({ group: { a: "[A]" } });
});
it("removes the obsolete string on cleanup", async () => {
await run({ cleanup: true });
expect(read("de.json")).toEqual({});
});
});
});
describe("maskJs", () => {
it("blanks comments and keeps every offset and newline", () => {
const code = `a(); // t("x")\n/* t("y")\n */ b();`;
const masked = maskJs(code);
expect(masked).toHaveLength(code.length);
expect(masked).not.toContain("t(");
expect(masked.split("\n")).toHaveLength(3);
expect(masked).toContain("a();");
expect(masked).toContain("b();");
});
it("keeps `//` inside strings", () => {
expect(maskJs(`const u = "https://example.com"; t("k");`)).toContain("https://example.com");
});
it("blanks the content of quoted strings only on request, and keeps template literals", () => {
const code = `x("a 't(' b", 'c'); y(\`\${t("k")}\`);`;
const bare = maskJs(code, true);
expect(bare).toHaveLength(code.length);
expect(bare).not.toContain("a 't(' b");
expect(bare).toContain('t("k")');
expect(maskJs(code)).toContain("a 't(' b");
});
it("does not take an apostrophe left open on a line for a string", () => {
expect(maskJs(`don't\nt("k")`, true)).toContain('t("');
});
});
describe("maskJs with template literals", () => {
const masked = (code: string) => maskJs(code, true);
it("template literal keeps its ${} expressions", () => {
const code = 'const example = `t("example.only") ${t("real.key")} end`;';
const bare = masked(code);
expect(bare).toHaveLength(code.length);
expect(bare).not.toContain("example.only");
expect(bare).toContain('t("real.key")');
expect(bare.startsWith("const example = `")).toBe(true);
expect(bare.endsWith("`;")).toBe(true);
});
it("keeps the text of a template literal when the strings are not masked", () => {
const code = 'const example = `t("example.only") ${t("real.key")}`; // t("c.d")';
const plain = maskJs(code);
expect(plain).toContain('`t("example.only") ${t("real.key")}`');
expect(plain).not.toContain("c.d");
});
it("keeps the offsets and the newlines of a template literal that spans lines", () => {
const code = "const a = `first t(\"x.y\")\nsecond ${\n t(\"real.key\")\n} third`;\nb();";
const bare = masked(code);
expect(bare).toHaveLength(code.length);
expect(bare.split("\n")).toHaveLength(code.split("\n").length);
expect(bare.indexOf('t("real.key")')).toBe(code.indexOf('t("real.key")'));
expect(bare).not.toContain("x.y");
expect(bare).toContain("b();");
});
it("does not take `//` or `/*` in the text of a template for a comment", () => {
const code = "const u = `https://example.com/*path*/` + t('k');";
expect(maskJs(code)).toBe(code);
expect(masked(code)).toMatch(/` \+ t\(' '\);$/);
});
it("masks a comment inside an expression and the text of a template nested in it", () => {
const code = "`${ /* t('c.d') */ cond ? `t(\"nested.text\")` : t('real.key') }`";
const bare = masked(code);
expect(bare).toHaveLength(code.length);
expect(bare).not.toContain("c.d");
expect(bare).not.toContain("nested.text");
expect(bare).toContain("t('real.key')");
});
it("closes an expression at its own brace, not at the brace of an object inside it", () => {
const code = "`a ${ fn({ x: 1 }) } t(\"after.only\") ${ t('real.key') }`";
const bare = masked(code);
expect(bare).not.toContain("after.only");
expect(bare).toContain("t('real.key')");
});
it("treats an escaped `${` and an escaped backtick as text", () => {
const code = "`\\${t('escaped.key')} \\` t('after.escape')` + t('real.key')";
const bare = masked(code);
expect(bare).toHaveLength(code.length);
expect(bare).not.toContain("escaped.key");
expect(bare).not.toContain("after.escape");
expect(bare).toMatch(/` \+ t\(' +'\)$/);
});
it("copes with a template or an expression that is never closed", () => {
for (const code of ["const a = `t('x.y')", "const a = `${t('x.y')", "const a = `trailing \\"]) {
expect(masked(code)).toHaveLength(code.length);
expect(maskJs(code)).toHaveLength(code.length);
}
});
});
describe("maskSource for .vue files", () => {
const vue = [
"<template>",
" <!-- {{ t('a.b') }} -->",
" <p>{{ t('c.d') }}</p>",
"</template>",
'<script setup lang="ts">',
"// t('e.f')",
"const x = t('g.h');",
"</script>",
"<style>",
".a::after { content: 't(\"i.j\")'; }",
"</style>",
].join("\n");
it("drops comments and styles but keeps the template and script code at the same offsets", () => {
const masked = maskSource("A.vue", vue);
expect(masked).toHaveLength(vue.length);
for (const dropped of ["a.b", "e.f", "i.j"]) expect(masked).not.toContain(dropped);
for (const kept of ["c.d", "g.h"]) expect(masked.indexOf(kept)).toBe(vue.indexOf(kept));
});
});
describe("literalCalls", () => {
const calls = (source: string) => [...literalCalls(maskJs(source), maskJs(source, true))];
it("reads a parameter that holds an escaped quote", () => {
const [call] = calls(`t("prompts.uploadSkipped", { count: "a\\"b".length })`);
expect(call?.params).toEqual({ names: new Set(["count"]), dynamic: false });
});
it("skips a key that is concatenated with an expression", () => {
expect(calls(`t('prompts.' + name)`)).toEqual([]);
expect(calls(`t("a" + b, { x: 1 })`)).toEqual([]);
});
it("ignores calls in comments and strings, and finds those in template literals", () => {
const found = calls(`// t('example.key')\nconst s = "t('example.str')";\nconst u = \`\${t("real.key")}\`;`);
expect(found.map((call) => call.key)).toEqual(["real.key"]);
});
it("does not check the text of a template literal as a call", () => {
expect(calls('const example = `t("example.only")`;')).toEqual([]);
expect(calls('const example = `see t("example.only") and ${t("real.key")}`;').map((call) => call.key)).toEqual(["real.key"]);
});
it("finds a call in an expression nested in a template, not in the template nested in that", () => {
const found = calls("const a = `${ cond ? `t('nested.text')` : t('real.key', { count: 1 }) }`;");
expect(found.map((call) => call.key)).toEqual(["real.key"]);
});
it("is not confused by a comment in the params", () => {
const [call] = calls(`t("k", { count, /* it's */ other })`);
expect(call?.params.names).toEqual(new Set(["count", "other"]));
});
});
-63
View File
@@ -1,63 +0,0 @@
import fs from "node:fs";
import { parse } from "vue/compiler-sfc";
import { isTestFile, maskSource, readLocale, rel, sourceFiles } from "./shared.ts";
const IGNORED_KEYS = [/^languages\./];
const IGNORED_ELEMENTS = new Set(["i", "v-icon", "kbd"]);
const IGNORE_COMMENT = "i18n-ignore";
// Vue's NodeTypes values
const ELEMENT = 1;
const TEXT = 2;
const COMMENT = 3;
interface TemplateNode {
type: number;
tag?: string;
content?: unknown;
props?: { name?: string; value?: { content: string } }[];
children?: TemplateNode[];
loc: { start: { line: number } };
}
/** The code of a source file that can use a key -- comments and <style> blocks are dropped */
const codeOf = (file: string) => maskSource(file, fs.readFileSync(file, "utf8"));
/** Every key in en.json must appear as a string literal in src/ or match a dynamic prefix */
export function checkUnusedKeys(): string[] {
const code = [...sourceFiles()].filter((file) => !isTestFile(file)).map(codeOf).join("\n");
const prefixes = [...code.matchAll(/\bt\(\s*["'`]([\w.-]+\.)(?:\$\{|["'`]\s*\+)/g)].map((match) => match[1] ?? "");
const isUsed = (key: string) =>
["'", '"', "`"].some((quote) => code.includes(`${quote}${key}${quote}`)) ||
prefixes.some((prefix) => key.startsWith(prefix));
return Object.keys(readLocale("en.json"))
.filter((key) => !IGNORED_KEYS.some((pattern) => pattern.test(key)) && !isUsed(key))
.map((key) => `en.json: unused key ${key}`);
}
const isIgnored = (node: TemplateNode) =>
IGNORED_ELEMENTS.has(node.tag ?? "") ||
(node.props ?? []).some((prop) => prop.name === "class" && prop.value?.content.includes("material-symbols"));
/** Templates shouldn't have plain text, to ignore them we can use <!-- i18n-ignore --> */
export function checkRawText(): string[] {
const problems: string[] = [];
for (const file of sourceFiles()) {
if (!file.endsWith(".vue")) continue;
const ast = parse(fs.readFileSync(file, "utf8"), { filename: file }).descriptor.template?.ast;
const walk = (node: TemplateNode) => {
if (node.type === ELEMENT && isIgnored(node)) return;
if (node.type === TEXT && typeof node.content === "string" && /\p{L}/u.test(node.content)) {
problems.push(`${rel(file)}:${node.loc.start.line}: raw text "${node.content.trim()}", use t()`);
}
let skip = false;
for (const child of node.children ?? []) {
if (!skip) walk(child);
if (child.type === COMMENT) skip = typeof child.content === "string" && child.content.trim() === IGNORE_COMMENT;
else if (child.type === ELEMENT || (child.type === TEXT && String(child.content).trim())) skip = false;
}
};
if (ast) walk(ast as unknown as TemplateNode);
}
return problems;
}
-40
View File
@@ -1,40 +0,0 @@
/**
* npm run i18n:types regenerate messages.generated.ts
* npm run i18n:sync regenerate the types, translate new keys (DEEPL_API_KEY)
* npm run i18n:enforce-order regenerate the types, reorder locales like en.json
* npm run i18n:cleanup regenerate the types, remove obsolete keys
* npm run i18n:check translations, types, unused keys and raw text
* npm run i18n:check:strict checks for raw `$t` and 'vue-i18n' imports, including JS files
*/
import { checkRawText, checkUnusedKeys } from "./check.ts";
import { report } from "./shared.ts";
import { checkTranslations, syncTranslations } from "./sync.ts";
import { checkTypes, writeTypes } from "./types.ts";
const [command, ...flags] = process.argv.slice(2);
const has = (flag: string) => flags.includes(flag);
try {
if (command === "types") {
process.exitCode = writeTypes();
} else if (command === "sync") {
process.exitCode = writeTypes() || (await syncTranslations({ enforceOrder: has("--enforce-order"), cleanup: has("--cleanup") }));
} else if (command === "check") {
const translations = checkTranslations();
const problems = [...translations, ...checkTypes(has("--strict")), ...checkUnusedKeys(), ...checkRawText()];
if (problems.length) {
report(problems);
if (translations.length) console.error("Run: npm run i18n:sync (or i18n:cleanup)");
process.exitCode = 1;
} else {
console.log("✅ i18n checks passed");
}
} else {
console.error("Usage: node scripts/i18n/index.ts <types | sync [--enforce-order | --cleanup] | check [--strict]>");
process.exitCode = 1;
}
} catch (error) {
console.error("\n❌ i18n script failed:", error);
process.exitCode = 1;
}
-156
View File
@@ -1,156 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parse } from "vue/compiler-sfc";
export const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
export const srcDir = path.join(root, "src");
export const i18nDir = path.join(srcDir, "i18n");
export const localesDir = path.join(i18nDir, "locales");
export const generatedFile = path.join(i18nDir, "messages.generated.ts");
export type Messages = Record<string, string>;
/** Tests t/$t with throwaway keys */
export const isTestFile = (file: string) => /\.test\.[jt]s$/.test(file);
export const rel = (file: string) => path.relative(root, file);
export const readJson = (file: string): unknown => JSON.parse(fs.readFileSync(file, "utf8"));
/** Flat nested messages into `{ "a.b.c": "message" }`; null counts as empty message. */
export function flatten(tree: unknown, prefix = ""): Messages {
const result: Messages = {};
for (const [key, value] of Object.entries(tree as Record<string, unknown>)) {
const fullKey = prefix + key;
if (typeof value === "string") {
result[fullKey] = value;
} else if (value === null) {
result[fullKey] = "";
} else if (typeof value === "object") {
Object.assign(result, flatten(value, `${fullKey}.`));
} else {
throw new Error(`${fullKey}: messages must be strings or objects`);
}
}
return result;
}
export const readLocale = (file: string): Messages => flatten(readJson(path.join(localesDir, file)));
/** File names of every locale except en.json, sorted */
export const translationFiles = (): string[] =>
fs
.readdirSync(localesDir)
.filter((file) => file.endsWith(".json") && file !== "en.json")
.sort();
/** .vue, .ts and .js in src/ without the locales and the generated types */
export function* sourceFiles(skipI18nDir = false, dir = srcDir): Generator<string> {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (full !== localesDir && !(skipI18nDir && full === i18nDir)) yield* sourceFiles(skipI18nDir, full);
} else if (/\.(vue|ts|js)$/.test(entry.name) && full !== generatedFile) {
yield full;
}
}
}
export function report(problems: string[]): void {
for (const problem of problems) console.error(`✗ ${problem}`);
console.error(`\n${problems.length} problem(s) found.`);
}
const blank = (text: string) => text.replace(/[^\n]/g, " ");
/** Replaces the comments of JS/TS code by spaces and, with `strings`, the content of '...' and "..." literals too */
export function maskJs(text: string, strings = false): string {
const scan = (start: number, inExpression: boolean): [string, number] => {
let out = "";
let i = start;
let depth = 0;
while (i < text.length) {
const ch = text.charAt(i);
const next = text.charAt(i + 1);
if (ch === "/" && (next === "/" || next === "*")) {
const close = next === "/" ? text.indexOf("\n", i) : text.indexOf("*/", i + 2);
const end = close === -1 ? text.length : next === "/" ? close : close + 2;
out += blank(text.slice(i, end));
i = end;
} else if (ch === "`") {
const [masked, end] = maskTemplate(i);
out += masked;
i = end;
} else if (ch === '"' || ch === "'") {
let end = i + 1;
while (end < text.length && text.charAt(end) !== ch) {
if (text.charAt(end) === "\\") end++;
else if (text.charAt(end) === "\n") break; // it's not a string
end++;
}
const closed = text.charAt(end) === ch;
const literal = text.slice(i, closed ? end + 1 : end);
out += strings && !inExpression && closed ? ch + blank(literal.slice(1, -1)) + ch : literal;
i += literal.length;
} else {
if (inExpression && ch === "{") depth++;
else if (inExpression && ch === "}") {
if (depth === 0) return [out, i];
depth--;
}
out += ch;
i++;
}
}
return [out, i];
};
// A template literal from its opening backtick -- its `${...}` expressions are scanned
const maskTemplate = (start: number): [string, number] => {
const keep = (chunk: string) => (strings ? blank(chunk) : chunk);
let out = "`";
let i = start + 1;
while (i < text.length) {
const ch = text.charAt(i);
if (ch === "\\") {
out += keep(text.slice(i, i + 2));
i += 2;
} else if (ch === "`") {
return [`${out}\``, i + 1];
} else if (ch === "$" && text.charAt(i + 1) === "{") {
const [code, end] = scan(i + 2, true);
out += `\${${code}`;
i = end;
if (text.charAt(i) === "}") {
out += "}";
i++;
}
} else {
out += keep(ch);
i++;
}
}
return [out, i];
};
return scan(0, false)[0];
}
/**
* The code of a source file without comments at the same offsets as the file. In .vue files only the template and the scripts
* are kept while styles no
*/
export function maskSource(file: string, text: string, strings = false): string {
if (!file.endsWith(".vue")) return maskJs(text, strings);
const { descriptor } = parse(text, { filename: file });
let out = text;
// each replacement keeps the length of what it replaces, so the blocks can be masked one after the other
const mask = ({ start, end }: { start: { offset: number }; end: { offset: number } }, apply: (code: string) => string) => {
out = out.slice(0, start.offset) + apply(text.slice(start.offset, end.offset)) + out.slice(end.offset);
};
for (const style of descriptor.styles) mask(style.loc, blank);
for (const script of [descriptor.script, descriptor.scriptSetup]) {
if (script) mask(script.loc, (code) => maskJs(code, strings));
}
if (descriptor.template) mask(descriptor.template.loc, (code) => code.replace(/<!--[\s\S]*?-->/g, blank));
return out;
}
-239
View File
@@ -1,239 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { DeepLClient, type TargetLanguageCode, type TranslateTextOptions } from "deepl-node";
import { localesDir, readJson, readLocale, translationFiles } from "./shared.ts";
type Tree = { [key: string]: unknown };
const MASTER_LANGUAGE = "en";
const DEEPL_LANGUAGES: Record<string, string> = {
"zh-cn": "ZH-HANS",
"zh-tw": "ZH-HANT",
pt: "PT-PT",
"pt-br": "PT-BR",
en: "EN",
"en-us": "EN-US",
"en-gb": "EN-GB",
"sv-se": "SV",
ua: "UK",
"nl-be": "NL",
cz: "CS",
bg: "BG",
};
let translator: DeepLClient | null = null;
const isTree = (value: unknown): value is Tree => typeof value === "object" && value !== null && !Array.isArray(value);
/** Reports what syncTranslations would change */
export function checkTranslations(): string[] {
const master = Object.keys(readLocale("en.json"));
const problems: string[] = [];
const translatable = (key: string) => !key.startsWith("languages.");
for (const file of translationFiles()) {
let target: Record<string, string>;
try {
target = readLocale(file);
} catch (error) {
problems.push(`${file}: cannot be read (${error instanceof Error ? error.message : error})`);
continue;
}
for (const key of master.filter(translatable)) {
if (!target[key]) problems.push(`${file}: missing "${key}"`);
}
for (const key of Object.keys(target).filter(translatable)) {
if (!master.includes(key)) problems.push(`${file}: obsolete key "${key}"`);
}
}
return problems;
}
async function translateText(text: string, targetLanguage: string, keyPath: string): Promise<string | null> {
if (!text.trim()) {
console.warn(`Skipping translation for empty text: "${text}"`);
return text;
}
if (keyPath === "languages" || keyPath.startsWith("languages.")) return text;
const hasPlaceholders = /\{[^}]+\}/.test(text);
let textToTranslate = text;
const options: TranslateTextOptions = {};
if (hasPlaceholders) {
console.log(`Found placeholder in: "${text}". Wrapping for translation.`);
textToTranslate = text.replace(/(\{[^}]+\})/g, "<ph>$1</ph>");
options.tagHandling = "xml";
options.ignoreTags = ["ph"];
}
try {
console.log(`Translating "${text}" from '${MASTER_LANGUAGE}' to '${targetLanguage}'...`);
const deeplTarget = (DEEPL_LANGUAGES[targetLanguage.toLowerCase()] ?? targetLanguage.toUpperCase()) as TargetLanguageCode;
const result = await translator?.translateText(textToTranslate, MASTER_LANGUAGE, deeplTarget, options);
await new Promise((resolve) => {setTimeout(resolve, 100)}); // avoid rate-limiting
let translated = result?.text ?? "";
if (hasPlaceholders) {
// the translator might add spaces around the tags
translated = translated.replace(/<ph>\s*(\{[^}]+\})\s*<\/ph>/g, "$1");
}
if (!translated.trim()) {
console.error(`⚠️ Translation failed for "${text}" (${keyPath}): empty result`);
return null;
}
return translated;
} catch (error) {
console.error(`⚠️ Translation failed for "${text}" (${keyPath}):`, error instanceof Error ? error.message : error);
return null;
}
}
/** Reorders `value` to match the key order of `master`, dropping keys `master` lacks */
function reorderObject(value: unknown, master: unknown): unknown {
if (!isTree(value) || !isTree(master)) return value;
const ordered: Tree = {};
for (const key of Object.keys(master)) {
if (Object.hasOwn(value, key)) ordered[key] = reorderObject(value[key], master[key]);
}
return ordered;
}
/** Add missing keys, remove obsolete ones */
async function processKeys(
master: Tree,
target: Tree,
lang: string,
parents: string[],
enforceOrder: boolean,
cleanup: boolean,
): Promise<boolean | "UNSUPPORTED"> {
let changed = false;
for (const key of Object.keys(master)) {
const masterValue = master[key];
const keyPath = [...parents, key].join(".");
// the "languages" block is always copied from master
if (key === "languages" && parents.length === 0 && !cleanup) {
if (!enforceOrder) console.log(`Copying entire "languages" object from master to ${lang}.json`);
target[key] = structuredClone(masterValue);
changed = true;
continue;
}
if (Object.hasOwn(target, key) && isTree(masterValue) !== isTree(target[key])) {
console.log(`🗑️ Removing "${keyPath}" from ${lang}.json: its structure differs from en.json`);
Reflect.deleteProperty(target, key);
changed = true;
}
if (isTree(masterValue)) {
if (!cleanup && !isTree(target[key])) {
console.log(`Creating missing object structure for "${keyPath}" in ${lang}.json`);
target[key] = {};
changed = true;
}
const child = target[key];
if (isTree(child)) {
const result = await processKeys(masterValue, child, lang, [...parents, key], enforceOrder, cleanup);
if (result === "UNSUPPORTED") {
console.log(`Skipping translation for "${lang}" due to unsupported structure.`);
return result;
}
if (result) changed = true;
}
} else if (!cleanup) {
if (typeof masterValue === "string") {
const existing = target[key];
if (!Object.hasOwn(target, key) || existing === "" || existing === null) {
if (enforceOrder) {
console.log(`Skipping new key "${keyPath}" for ${lang}.json`);
} else {
const translated = await translateText(masterValue, lang, keyPath);
if (translated === null) return "UNSUPPORTED";
if (target[key] !== translated) {
target[key] = translated;
changed = true;
}
}
}
} else if (!Object.hasOwn(target, key)) {
console.log(`Key "${keyPath}" (non-string) missing in ${lang}.json. Copying from English.`);
target[key] = masterValue;
changed = true;
}
}
}
for (const key of Object.keys(target)) {
if (!Object.hasOwn(master, key)) {
console.log(`🗑️ Removing obsolete key "${[...parents, key].join(".")}" from ${lang}.json`);
Reflect.deleteProperty(target, key);
changed = true;
}
}
return changed;
}
export async function syncTranslations({ enforceOrder, cleanup }: { enforceOrder: boolean; cleanup: boolean }): Promise<number> {
const reorder = enforceOrder && !cleanup;
if (!enforceOrder && !cleanup) {
const apiKey = process.env.DEEPL_API_KEY;
if (!apiKey) {
console.error("❌ Missing DEEPL_API_KEY in environment.");
return 1;
}
translator = new DeepLClient(apiKey);
console.warn("--- Using DeepL API for translation ---");
} else if (cleanup) {
console.warn("--- Only obsolete keys will be removed - No translations, or reordering will happen ---");
} else {
console.warn("--- Enforcing order of all files to match master language (no translations will be performed) ---");
}
const master = readJson(path.join(localesDir, "en.json")) as Tree;
const failed: string[] = [];
for (const file of translationFiles()) {
const targetFile = path.join(localesDir, file);
const lang = path.basename(file, ".json");
let original: Tree;
try {
const parsed = readJson(targetFile);
if (!isTree(parsed)) throw new Error("the root must be an object");
original = parsed;
} catch (error) {
console.error(`❌ Could not read ${targetFile}, leaving it unchanged: ${error instanceof Error ? error.message : error}`);
failed.push(file);
continue;
}
let target = structuredClone(original);
const result = await processKeys(master, target, lang, [], reorder, cleanup);
if (result === "UNSUPPORTED") {
console.error(`❌ ${targetFile} was left unchanged: a translation failed.`);
failed.push(file);
continue;
}
let orderChanged = false;
if (reorder) {
target = reorderObject(target, master) as Tree;
orderChanged = JSON.stringify(original) !== JSON.stringify(target);
if (orderChanged) console.log(`- Reordered keys in ${lang}.json`);
}
if (result || orderChanged) {
fs.writeFileSync(targetFile, `${JSON.stringify(target, null, 2)}\n`);
if (!cleanup && !reorder) console.log(`Successfully updated ${targetFile}`);
} else {
console.log(`No changes needed for ${targetFile}`);
}
}
if (failed.length) {
console.error(`\n❌ Synchronization incomplete, ${failed.length} locale(s) were not changed: ${failed.join(", ")}`);
return 1;
}
if (cleanup) console.log("\n✅ Cleanup complete (obsolete keys removed).");
else if (reorder) console.log("\n✅ Enforce order complete.");
else console.log("\n✅ Translation synchronization complete (via DeepL).");
return 0;
}
-289
View File
@@ -1,289 +0,0 @@
import fs from "node:fs";
import { generatedFile, isTestFile, maskSource, type Messages, readLocale, rel, sourceFiles, translationFiles } from "./shared.ts";
const OPTIONAL_PLACEHOLDERS = new Set(["suffix", "prefix"]);
const NAMED_PLACEHOLDER = /\{([A-Za-z_]\w*)\}/g;
/** vue-i18n literal interpolation, like {'|'} to print a pipe or @ */
const LITERAL_INTERPOLATION = /\{'[^']*'\}/g;
const REGENERATE = "Run: npm run i18n:types";
/** Named placeholders of a message in order */
function placeholders(message: string): string[] {
const names: string[] = [];
for (const match of message.matchAll(NAMED_PLACEHOLDER)) {
const name = match[1] ?? "";
if (!names.includes(name)) names.push(name);
}
return names;
}
/** Returns a description of unsupported vue-i18n syntax, or null. */
function unsupportedSyntax(message: string): string | null {
const rest = message.replace(LITERAL_INTERPOLATION, "").replace(NAMED_PLACEHOLDER, "");
if (/[{}]/.test(rest)) return "braces other than {name} placeholders";
if (rest.includes("|")) return "plural separator '|'";
if (/@[.:]/.test(rest)) return "linked message '@:' / '@.'";
return null;
}
function generate() {
const messages = readLocale("en.json");
const problems: string[] = [];
const lines: string[] = [];
for (const [key, message] of Object.entries(messages)) {
const unsupported = unsupportedSyntax(message);
if (unsupported) problems.push(`en.json: ${key} uses ${unsupported}, which the types do not support`);
const names = placeholders(message);
const params = names.length ? names.map((name) => JSON.stringify(name)).join(" | ") : "never";
lines.push(` ${JSON.stringify(key)}: ${params};`);
}
const content = `// This file is generated by scripts/i18n from src/i18n/locales/en.json.
// Run \`make generate-translation-types\` \`npm run i18n:types\` after changing en.json. Do not edit manually.
/** Every message in en.json mapped to the named placeholders. Used to type-check \`t()\` calls */
export interface MessageParams {
${lines.join("\n")}
}
/** Placeholders that callers may omit */
export type OptionalParam = ${[...OPTIONAL_PLACEHOLDERS].map((name) => JSON.stringify(name)).join(" | ")};
`;
return { content, messages, problems };
}
/** Regenerates messages.generated.ts; returns the exit code. */
export function writeTypes(): number {
const { content, messages, problems } = generate();
if (problems.length) {
for (const problem of problems) console.error(`✗ ${problem}`);
return 1;
}
fs.writeFileSync(generatedFile, content);
console.log(`Wrote ${rel(generatedFile)} (${Object.keys(messages).length} messages).`);
return 0;
}
function checkGeneratedFile(expected: string): string[] {
if (!fs.existsSync(generatedFile)) return [`${rel(generatedFile)} is missing. ${REGENERATE}`];
if (fs.readFileSync(generatedFile, "utf8") !== expected) return [`${rel(generatedFile)} is out of date. ${REGENERATE}`];
return [];
}
function checkLocalePlaceholders(enMessages: Messages): string[] {
const problems: string[] = [];
for (const file of translationFiles()) {
for (const [key, message] of Object.entries(readLocale(file))) {
const english = enMessages[key];
if (english === undefined) continue;
const expected = new Set(placeholders(english));
const actual = new Set(placeholders(message));
for (const name of actual) {
if (!expected.has(name)) problems.push(`${file}: ${key} uses {${name}}, which en.json does not provide`);
}
for (const name of expected) {
if (!actual.has(name) && !OPTIONAL_PLACEHOLDERS.has(name)) problems.push(`${file}: ${key} is missing {${name}}`);
}
const unsupported = unsupportedSyntax(message);
if (unsupported) problems.push(`${file}: ${key} uses ${unsupported}`);
}
}
return problems;
}
/** .ts files and .vue files with <script lang="ts"> are checked by vue-tsc */
function isTypeScript(file: string, text: string): boolean {
if (file.endsWith(".ts")) return true;
return file.endsWith(".vue") && /<script[^>]*lang=["']ts["']/.test(text);
}
const BYPASS_RULES = [
{
pattern: /from\s+["']vue-i18n["']/,
message: "import useI18n from '@/i18n/index.ts' instead of 'vue-i18n'",
inStrings: true,
},
{
pattern: /(^|[^\w$])\$t\b/,
message: "use t(...) from useI18n() in '@/i18n/index.ts' instead of $t(...), so the key and params are type-checked",
inStrings: false,
},
];
// The start of a t(...) call: t(, $t(, this.$t( or i18n.global.t(
const CALL_START = /(?:(?<![\w$.])(?:\$t|t|i18n\.global\.t)|\bthis\.\$t)\(/g;
interface Params {
names: Set<string>;
dynamic: boolean;
}
const lineOf = (text: string, index: number) => text.slice(0, index).split("\n").length;
function skipSpaces(text: string, index: number): number {
let i = index;
while (i < text.length && /\s/.test(text.charAt(i))) i++;
return i;
}
/** Reads a string literal starting at `index`; returns null for anything else or a dynamic template */
function readStringLiteral(text: string, index: number): { value: string; end: number } | null {
const quote = text.charAt(index);
if (quote !== '"' && quote !== "'" && quote !== "`") return null;
let i = index + 1;
let value = "";
while (i < text.length && text.charAt(i) !== quote) {
if (text.charAt(i) === "\\") {
value += text.charAt(i + 1);
i += 2;
continue;
}
if (quote === "`" && text.charAt(i) === "$" && text.charAt(i + 1) === "{") return null;
value += text.charAt(i);
i++;
}
return i < text.length ? { value, end: i + 1 } : null;
}
const isQuote = (ch: string) => ch === '"' || ch === "'" || ch === "`";
function skipString(text: string, index: number): number {
const quote = text.charAt(index);
let i = index + 1;
while (i < text.length) {
const ch = text.charAt(i);
if (ch === "\\") i++;
else if (ch === quote) return i + 1;
i++;
}
return -1;
}
const CLOSERS: Record<string, string> = { "{": "}", "[": "]", "(": ")" };
/** Finds the index of the bracket closing the one at `index`, skipping strings. */
function findClosing(text: string, index: number): number {
const stack = [CLOSERS[text.charAt(index)]];
let i = index + 1;
while (i < text.length && stack.length) {
const ch = text.charAt(i);
if (isQuote(ch)) {
const end = skipString(text, i);
if (end === -1) return -1;
i = end;
continue;
}
const closer = CLOSERS[ch];
if (closer) stack.push(closer);
else if (ch === stack.at(-1)) stack.pop();
i++;
}
return stack.length ? -1 : i - 1;
}
/** Property names of the object literal at `index`; `dynamic` when a spread or computed key hides some */
function readObjectKeys(text: string, index: number): Params | null {
const close = findClosing(text, index);
if (close === -1) return null;
const names = new Set<string>();
let dynamic = false;
let segmentStart = index + 1;
let i = index + 1;
const flush = (end: number) => {
const segment = text.slice(segmentStart, end).trim();
if (!segment) return;
if (segment.startsWith("...") || segment.startsWith("[")) {
dynamic = true;
return;
}
const match = segment.match(/^(?:([A-Za-z_$][\w$]*)|'([^']*)'|"([^"]*)")\s*(?::|$)/);
const name = match?.[1] ?? match?.[2] ?? match?.[3];
if (name === undefined) dynamic = true;
else names.add(name);
};
while (i < close) {
const ch = text.charAt(i);
if (isQuote(ch)) {
i = skipString(text, i);
continue;
}
if (ch === "{" || ch === "[" || ch === "(") {
i = findClosing(text, i) + 1;
continue;
}
if (ch === ",") {
flush(i);
segmentStart = i + 1;
}
i++;
}
flush(close);
return { names, dynamic };
}
/**
* Yields every t(...) call whose first argument is a literal key. `text` is the source without
* comments, `bare` the same without the content of its strings
*/
export function* literalCalls(text: string, bare: string): Generator<{ key: string; params: Params; index: number }> {
for (const match of bare.matchAll(CALL_START)) {
const keyStart = skipSpaces(text, match.index + match[0].length);
const key = readStringLiteral(text, keyStart);
if (!key) continue; // only vue-tsc can check it
let i = skipSpaces(text, key.end);
if (text.charAt(i) !== "," && text.charAt(i) !== ")") continue; // 'prefix.' + name if the key isn't literal
let params: Params = { names: new Set(), dynamic: false };
if (text.charAt(i) === ",") {
i = skipSpaces(text, i + 1);
if (text.charAt(i) === "{") params = readObjectKeys(text, i) ?? params;
else if (text.charAt(i) !== ")") params = { names: new Set(), dynamic: true };
}
yield { key: key.value, params, index: match.index };
}
}
function checkCall(key: string, params: Params, messages: Messages): string[] {
const expected = messages[key];
if (expected === undefined) return [`unknown translation key "${key}"`];
if (params.dynamic) return []; // spread or variable: names unknown
const known = new Set(placeholders(expected));
const problems: string[] = [];
for (const name of params.names) {
if (!known.has(name)) problems.push(`"${key}" does not take a {${name}} param`);
}
for (const name of known) {
if (!params.names.has(name) && !OPTIONAL_PLACEHOLDERS.has(name)) problems.push(`"${key}" is missing its {${name}} param`);
}
return problems;
}
function checkTypedUsage(messages: Messages, strict: boolean): string[] {
const problems: string[] = [];
for (const file of sourceFiles(true)) {
if (isTestFile(file)) continue; // tests t/$t with throwaway keys
const text = fs.readFileSync(file, "utf8");
const code = maskSource(file, text);
const bare = maskSource(file, text, true);
if (strict || isTypeScript(file, text)) {
const codeLines = code.split("\n");
const bareLines = bare.split("\n");
for (const rule of BYPASS_RULES) {
const lines = rule.inStrings ? codeLines : bareLines;
for (const [index, line] of lines.entries()) {
if (rule.pattern.test(line)) problems.push(`${rel(file)}:${index + 1}: ${rule.message}`);
}
}
}
for (const call of literalCalls(code, bare)) {
for (const problem of checkCall(call.key, call.params, messages)) {
problems.push(`${rel(file)}:${lineOf(text, call.index)}: ${problem}`);
}
}
}
return problems;
}
/** Checks the generated types, the placeholders of every locale and the t() calls in src/. With `strict`, raw `$t` and 'vue-i18n' */
export function checkTypes(strict: boolean): string[] {
const { content, messages, problems } = generate();
return [...problems, ...checkGeneratedFile(content), ...checkLocalePlaceholders(messages), ...checkTypedUsage(messages, strict)];
}
+351
View File
@@ -0,0 +1,351 @@
// scripts/sync-translations.js
import path from 'node:path';
import { globSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { Translator } from 'deepl-node';
import fs from 'fs-extra';
// Parse command line arguments
const args = process.argv.slice(2);
const checkOnly = args.includes('--check') || args.includes('-c');
const enforceOrder = args.includes('--enforce-order'); // When using this will force the order of eng, will not perform new translations. If we move keys to a different block will count as new translations and will delete it from all the languages
const cleanupOnly = args.includes('--cleanup'); // Will only delete keys that not longer exist in eng, no translation or reorder, just deletion.
// --- Configuration ---
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const localesDir = path.resolve(__dirname, '../src/i18n');
const masterLocaleFile = path.join(localesDir, 'en.json');
const masterLanguageCode = 'en';
const targetLocaleFiles = globSync(path.join(localesDir, '*.json'))
.filter(file => path.basename(file) !== `${masterLanguageCode}.json`)
const requireApiKey = !checkOnly && !enforceOrder && !cleanupOnly;
const DEEPL_API_KEY = process.env.DEEPL_API_KEY;
if (requireApiKey && !DEEPL_API_KEY) {
console.error("❌ Missing DEEPL_API_KEY in environment.");
process.exit(1);
}
const translator = requireApiKey ? new Translator(DEEPL_API_KEY) : null;
const deeplLangMap = {
'zh-cn': 'ZH-HANS',
'zh-tw': 'ZH-HANT',
'pt': 'PT-PT', // or 'PT-BR' if you want Brazilian Portuguese
'pt-br': 'PT-BR',
'en': 'EN',
'en-us': 'EN-US',
'en-gb': 'EN-GB',
'sv-se': 'SV',
'ua': 'UK',
'nl-be': 'NL',
'cz': 'CS',
'bg': 'BG',
// Add more as needed
};
// --- Translation Function using DeepL ---
async function translateText(text, targetLanguage, keyPath = '') {
if (checkOnly) {
// In check mode, just return the original text to detect changes
return text;
}
if (!text || typeof text !== 'string' || text.trim() === '') {
console.warn(`Skipping translation for empty or non-string text: "${text}"`);
return text;
}
if (keyPath === 'languages' || keyPath.startsWith('languages.')) {
return text;
}
const hasPlaceholders = /\{[^}]+\}/.test(text);
let textToTranslate = text;
const options = {};
if (hasPlaceholders) {
console.log(`Found placeholder in: "${text}". Wrapping for translation.`);
textToTranslate = text.replace(/(\{[^}]+\})/g, '<ph>$1</ph>');
options.tagHandling = 'xml';
options.ignoreTags = ['ph'];
}
try {
console.log(`Translating "${text}" from '${masterLanguageCode}' to '${targetLanguage}'...`);
const deeplTargetLang = deeplLangMap[targetLanguage.toLowerCase()] || targetLanguage.toUpperCase();
const result = await translator.translateText(textToTranslate, masterLanguageCode, deeplTargetLang, options);
// Delay to avoid rate-limiting
await new Promise(resolve => setTimeout(resolve, 100));
let translatedText = result.text;
if (hasPlaceholders) {
// Unwrap the placeholders. The translator might add spaces around tags.
translatedText = translatedText.replace(/<ph>\s*(\{[^}]+\})\s*<\/ph>/g, '$1');
}
return translatedText;
} catch (err) {
console.error(`⚠️ Translation failed for "${text}" (${keyPath}):`, err.message || err);
return ``;
}
}
// Reorder objects to match eng (master lang) key order
function reorderObject(obj, masterObj) {
// If obj is not an object, return as is
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return obj;
const newObj = {};
for (const key in masterObj) {
if (Object.hasOwn(masterObj, key)) {
if (Object.hasOwn(obj, key)) {
newObj[key] = reorderObject(obj[key], masterObj[key]);
}
}
}
return newObj;
}
// --- Recursive key processor ---
async function processKeys(masterObj, targetObj, targetLangCode, currentPathParts = [], doEnforceOrder, doCleanupOnly = false) {
let changesMade = false;
let meaningfulChanges = 0; // Only count meaningful changes
// First pass: Add/update keys from master to target
for (const key in masterObj) {
if (Object.hasOwn(masterObj, key)) {
const currentPathPartsNext = [...currentPathParts, key];
const currentKeyPath = currentPathPartsNext.join('.');
const masterValue = masterObj[key];
// Special handling for "languages" key - always copy the entire object from master
if (key === 'languages' && currentPathParts.length === 0 && !doCleanupOnly) {
if (!checkOnly && !doEnforceOrder) {
console.log(`Copying entire "languages" object from master to ${targetLangCode}.json`);
}
targetObj[key] = JSON.parse(JSON.stringify(masterValue)); // Deep copy
changesMade = true;
continue;
}
if (typeof masterValue === 'object' && masterValue !== null && !Array.isArray(masterValue)) {
if (!doCleanupOnly && (!targetObj[key] || typeof targetObj[key] !== 'object')) {
if (checkOnly) {
console.log(`Would create missing object structure for "${currentKeyPath}" in ${targetLangCode}.json`);
meaningfulChanges++; // Creating new object structures is meaningful
// In check mode, create a temporary object for processing
targetObj[key] = {};
} else {
console.log(`Creating missing object structure for "${currentKeyPath}" in ${targetLangCode}.json`);
targetObj[key] = {};
}
changesMade = true;
}
if (targetObj[key] && typeof targetObj[key] === 'object') {
const result = await processKeys( masterValue, targetObj[key], targetLangCode, currentPathPartsNext,
doEnforceOrder, doCleanupOnly);
if (result === "UNSUPPORTED") {
console.log(`Skipping translation for "${targetLangCode}" due to unsupported structure.`);
return "UNSUPPORTED";
}
if (typeof result === 'number') {
meaningfulChanges += result;
changesMade = true;
} else if (result) {
changesMade = true;
}
}
} else if (!doCleanupOnly) {
// Only handle non‑object values
if (typeof masterValue === 'string') {
const existingTargetValue = targetObj[key];
const isMissing = !Object.hasOwn(targetObj, key) || existingTargetValue === '' || existingTargetValue === null;
if (isMissing) {
if (checkOnly) {
console.log(`Would translate "${currentKeyPath}" for ${targetLangCode}.json`);
meaningfulChanges++; // Translation is meaningful
} else if (doEnforceOrder) {
// Skip translations for new keys
console.log(`Skipping new key "${currentKeyPath}" for ${targetLangCode}.json`);
} else {
// Sync translation
const result = await translateText(masterValue, targetLangCode, currentKeyPath);
if (result === "") {
return "UNSUPPORTED";
}
targetObj[key] = result;
changesMade = true;
}
}
} else {
if (!Object.hasOwn(targetObj, key)) {
if (checkOnly) {
console.log(`Would copy key "${currentKeyPath}" (non-string) from English to ${targetLangCode}.json`);
meaningfulChanges++; // Copying non-string values is meaningful
} else {
console.log(`Key "${currentKeyPath}" (non-string) missing in ${targetLangCode}.json. Copying from English.`);
targetObj[key] = masterValue;
}
changesMade = true;
}
}
}
}
}
// Second pass: Remove obsolete keys that exist in target but not in master
const keysToRemove = [];
for (const key in targetObj) {
if (Object.hasOwn(targetObj, key)) {
if (!Object.hasOwn(masterObj, key)) {
keysToRemove.push(key);
}
}
}
for (const key of keysToRemove) {
const currentKeyPath = [...currentPathParts, key].join('.');
if (checkOnly) {
console.log(`Would remove obsolete key "${currentKeyPath}" from ${targetLangCode}.json`);
meaningfulChanges++; // Removing obsolete keys is meaningful
} else {
console.log(`🗑️ Removing obsolete key "${currentKeyPath}" from ${targetLangCode}.json`);
delete targetObj[key];
}
changesMade = true;
}
return checkOnly ? meaningfulChanges : changesMade;
}
// --- Main synchronization ---
async function syncAllTranslations() {
const isSync = !checkOnly && !enforceOrder && !cleanupOnly;
const isEnforceOrder = enforceOrder && !cleanupOnly;
const isCleanup = cleanupOnly;
if (checkOnly) {
console.log("--- Checking for translation changes (no translations will be performed) ---");
} else if (isCleanup) {
console.warn("--- Only obsolete keys will be removed - No translations, or reordering will happen ---");
} else if (isEnforceOrder) {
console.warn("--- Enforcing order of all files to match master language (no translations will be performed) ---");
} else if (isSync) {
console.warn("--- Using DeepL API for translation ---");
}
if (!await fs.pathExists(masterLocaleFile)) {
console.error(`Master locale file not found: ${masterLocaleFile}`);
process.exit(1);
}
const masterContent = await fs.readJson(masterLocaleFile);
console.log(`Loaded master translations from ${masterLocaleFile}`);
let meaningfulChanges = 0;
let hasMeaningfulChanges = false;
for (const targetFile of targetLocaleFiles) {
const targetLangCode = path.basename(targetFile, '.json');
let targetContent = {};
let originalTargetContent = null;
const fileExisted = await fs.pathExists(targetFile);
if (fileExisted) {
try {
originalTargetContent = await fs.readJson(targetFile);
targetContent = JSON.parse(JSON.stringify(originalTargetContent)); // start from original
} catch (e) {
console.warn(`Warning: Could not parse ${targetFile}. Starting fresh. Error: ${e.message}`);
targetContent = {};
originalTargetContent = null;
}
} else {
if (isCleanup) continue;
console.log(`\nTarget file ${targetFile} not found. Will create for language: ${targetLangCode}.`);
}
const result = await processKeys(masterContent, targetContent, targetLangCode, [], isEnforceOrder, isCleanup);
let reorderedContent = targetContent;
let orderChanged = false;
if (isEnforceOrder && originalTargetContent && !checkOnly) {
reorderedContent = reorderObject(targetContent, masterContent);
// Compare the reordered content with the original to see if order changed
const origStr = JSON.stringify(originalTargetContent);
const reorderedStr = JSON.stringify(reorderedContent);
if (origStr !== reorderedStr) {
orderChanged = true;
console.log(`- Reordered keys in ${targetLangCode}.json`);
}
}
if (checkOnly) {
let wouldChange = false;
if (typeof result === 'number' && result > 0) {
meaningfulChanges += result;
wouldChange = true;
console.log(`Found ${result} meaningful changes needed for ${targetLangCode}.json`);
}
if (!fileExisted && !isCleanup) {
wouldChange = true;
console.log(`Would create new file for ${targetLangCode}.json`);
}
if (wouldChange) {
hasMeaningfulChanges = true;
}
} else {
const needsWrite = result || orderChanged || (!isCleanup && !fileExisted);
if (needsWrite) {
const finalContent = (isEnforceOrder && orderChanged) ? reorderedContent : targetContent;
try {
await fs.writeJson(targetFile, finalContent, { spaces: 2 });
if (!isCleanup && !isEnforceOrder) {
console.log(`Successfully ${result ? 'updated' : 'created'} ${targetFile}`);
}
} catch (error) {
console.error(`Error writing to ${targetFile}:`, error);
}
} else {
console.log(`No changes needed for ${targetFile}`);
}
}
}
if (checkOnly) {
if (hasMeaningfulChanges) {
console.log(`\n⚠️ Found ${meaningfulChanges} meaningful translation changes needed. -- update via "make sync-translations"`);
return 1; // Exit code 1 for meaningful changes needed
} else {
console.log('\n✅ No meaningful translation changes needed.');
return 0; // Exit code 0 for no meaningful changes
}
} else {
if (isCleanup) {
console.log('\n✅ Cleanup complete (obsolete keys removed).');
} else if (isEnforceOrder) {
console.log('\n✅ Enforce order complete.');
} else {
console.log('\n✅ Translation synchronization complete (via DeepL).');
}
return 0;
}
}
syncAllTranslations()
.then(exitCode => {
process.exit(exitCode);
})
.catch(error => {
console.error("\n❌ An error occurred during translation synchronization:", error);
process.exit(1);
});
+2 -2
View File
@@ -4,8 +4,8 @@
<script>
import { onMounted } from "vue";
import { mutations } from "@/store/index.ts"; // Import your store's mutations
import { scheduleFullIcons } from "@/utils/loadFullIcons.js";
import { mutations } from "@/store"; // Import your store's mutations
import { scheduleFullIcons } from "@/utils/loadFullIcons";
mutations.setLoading("main-app", true);
export default {
+2 -2
View File
@@ -1,6 +1,6 @@
import { notify } from "@/notify/index.ts"
import { getters, mutations, state } from "@/store/index.ts"
import { globalVars } from "@/utils/constants.ts"
import { globalVars } from "@/utils/constants.js"
import { downloadManager } from "@/utils/downloadManager.ts"
import {
notifyDownloadComplete,
@@ -34,7 +34,7 @@ function parseMockDataCount(value, defaultCount) {
if (value === undefined || value === null || value === '') {
return defaultCount;
}
const n = Math.trunc(Number(value));
const n = parseInt(String(value), 10);
if (!Number.isFinite(n) || n < 0) {
return defaultCount;
}
+1 -1
View File
@@ -51,7 +51,7 @@ function readCacheForScope(scope) {
if (!parsed?.viewToken || !parsed?.expiresAt) {
return null;
}
return { viewToken: parsed.viewToken, expiresAt: parsed.expiresAt };
return parsed;
} catch {
return null;
}
+3 -6
View File
@@ -18,7 +18,7 @@
close: multistate === 'close',
}"
>
<title id="toggle-navbar-title">{{ t('general.close') }}</title>
<title id="toggle-navbar-title">{{ $t('general.close') }}</title>
<path class="line top" d="M 30,33 H 70" />
<path class="line middle" d="M 30,50 H 70" />
<path class="line bottom" d="M 30,67 H 70" />
@@ -56,9 +56,8 @@ export function actionIconClass(icon: string, customIconClass?: string): string
<script setup lang="ts">
import { computed } from "vue";
import { getters, mutations } from "@/store/index.ts";
import type { Prompt } from "@/store/types.ts";
import { useI18n } from "@/i18n/index.ts";
import { getters, mutations } from "@/store";
import type { Prompt } from "@/store/types";
const props = withDefaults(
defineProps<{
@@ -83,8 +82,6 @@ const emit = defineEmits<{
action: [];
}>();
const { t } = useI18n();
const multistate = computed(() => getters.multibuttonState());
const iconClass = computed(() => actionIconClass(props.icon, props.customIconClass));
+3 -8
View File
@@ -44,12 +44,6 @@ export default {
default: false,
},
},
emits: {
"button-clicked": (_value) => true,
"remove-button-clicked": (_value) => true,
disableAll: () => true,
enableAll: () => true,
},
data() {
return {
activeButton: null, // Initially no button is active
@@ -83,11 +77,12 @@ export default {
this.$emit("remove-button-clicked", value);
} else {
// Emit remove-button-clicked for all other indexes
for (const [idx, button] of this.buttons.entries()) {
this.buttons.forEach((button, idx) => {
if (idx !== index) {
this.$emit("remove-button-clicked", button.value);
}
}
});
this.activeButton = index;
this.$emit("button-clicked", value);
}
@@ -47,10 +47,7 @@ vi.mock("@/store", () => ({
isSplitViewActive: () => false,
},
}));
vi.mock("@/i18n/index.ts", () => ({
default: { global: { t: (key: string) => key } },
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("vue-router", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) }));
vi.mock("@/api", () => ({ resourcesApi: {}, shareApi: {}, usersApi: {} }));
vi.mock("@/notify", () => ({ notify: {} }));
+15 -15
View File
@@ -22,7 +22,7 @@
</div>
<hr class="divider">
<p v-if="newFileTemplates.length === 0 && isLoggedIn" class="context-menu-empty-message">
{{ t('prompts.noTemplate') }}
{{ $t('prompts.noTemplate') }}
</p>
<button
v-for="(item, index) in newFileTemplateItems"
@@ -39,12 +39,12 @@
<button
type="button"
class="action no-select"
:aria-label="t('files.emptyFile')"
:title="t('files.emptyFile')"
:aria-label="$t('files.emptyFile')"
:title="$t('files.emptyFile')"
@click="createEmptyFile"
>
<i :class="actionIconClass('insert_drive_file')">insert_drive_file</i>
<span>{{ t('files.emptyFile') }}</span>
<span>{{ $t('files.emptyFile') }}</span>
</button>
</template>
<template v-else>
@@ -66,7 +66,7 @@
<div
v-if="selectedCount > 0"
@mouseleave="hideInteractiveTooltip()"
@mouseenter="showHoverTooltip(t('buttons.selectedCount'), $event)"
@mouseenter="showHoverTooltip($t('buttons.selectedCount'), $event)"
class="button selected-count-header"
:class="{ 'selected-count-header--circle': selectedCount <= 99 }"
>
@@ -112,21 +112,21 @@
<script setup lang="ts">
import { computed, markRaw, onBeforeUnmount, onMounted, ref, toRaw, watch } from "vue";
import { useI18n } from "@/i18n/index.ts";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { resourcesApi, shareApi, usersApi } from "@/api/index.js";
import { notify } from "@/notify/index.ts";
import { getters, mutations, state } from "@/store/index.ts";
import { resourcesApi, shareApi, usersApi } from "@/api";
import { notify } from "@/notify";
import { getters, mutations, state } from "@/store/index.js";
import type { FileListItem, ReqObject } from "@/store/types.ts";
import { hideInteractiveTooltip, showHoverTooltip } from "@/utils/tooltipHelp.js";
import { goToItem, getParentDir, removeLastDir } from "@/utils/url.js";
import { copyToClipboard } from "@/utils/clipboard.js";
import downloadFiles from "@/utils/download.js";
import { canNativeShare, nativeShareFile } from "@/utils/nativeShare.js";
import { isRichTextPreviewMimeType, getTypeInfoFromExt } from "@/utils/mimetype.js";
import { isMediaRequest } from "@/utils/mediaFile.js";
import { copyToClipboard } from "@/utils/clipboard";
import downloadFiles from "@/utils/download";
import { canNativeShare, nativeShareFile } from "@/utils/nativeShare";
import { isRichTextPreviewMimeType, getTypeInfoFromExt } from "@/utils/mimetype";
import { isMediaRequest } from "@/utils/mediaFile";
import { actionIconClass } from "@/components/Action.vue";
import { popEnter, popLeave } from "@/utils/transitions.ts";
import { popEnter, popLeave } from "@/utils/transitions";
type MenuItem = FileListItem & { isDir?: boolean; from?: string };
@@ -27,7 +27,7 @@ import {
createLightningStrike,
prefersReducedMotion,
type LightningStrike,
} from "@/utils/lightning-bolt.ts";
} from "@/utils/lightning-bolt";
type Bolt = LightningStrike & { id: number };
+1 -1
View File
@@ -8,7 +8,7 @@
</template>
<script>
import { notify } from "@/notify/index.ts";
import { notify } from "@/notify";
export default {
name: "load-failed",
+5 -7
View File
@@ -11,7 +11,7 @@
@keydown.esc.prevent="$emit('close')"
/>
</div>
<div class="menu-option-list-scroll" :style="scrollStyle">
<div ref="scroll" class="menu-option-list-scroll" :style="scrollStyle">
<button
v-for="option in filteredOptions"
:key="optionKey(option.value)"
@@ -24,7 +24,7 @@
}"
:aria-selected="isSelected(option.value) ? 'true' : 'false'"
:disabled="option.disabled"
@click="$emit('option-select', option)"
@click="$emit('select', option)"
>
<span>{{ option.label }}</span>
</button>
@@ -69,11 +69,9 @@ export default {
default: undefined,
},
},
emits: {
"option-select": (_option) => true,
"update:searchQuery": (_query) => true,
close: () => true,
},
emits: ["select", "update:searchQuery", "close"],
computed: {
resolvedSearchPlaceholder() {
return this.searchPlaceholder || this.$t("general.search");
+1 -1
View File
@@ -55,7 +55,7 @@
</template>
<script>
import { notify } from "@/notify/index.ts";
import { notify } from "@/notify";
export default {
name: "notifications",
+12 -16
View File
@@ -47,15 +47,12 @@ export default {
default: 0,
},
max: {
type: [Number, String],
default: 100,
},
valBackground: {
type: [Number, String],
"val-background": {
default: null,
},
valText: {
type: [Number, String],
"val-text": {
default: null,
},
unit: {
@@ -63,24 +60,23 @@ export default {
default: "",
},
size: {
type: [Number, String],
// either a number (pixel width/height) or 'tiny', 'small',
// 'medium', 'large', 'huge', 'massive' for common sizes
default: "big",
},
bgColor: {
"bg-color": {
type: String,
default: "#eee",
},
barColor: {
"bar-color": {
type: String,
default: "var(--primaryColor)", // match .blue color to Material Design's 'Blue 500' color
},
barTransition: {
"bar-transition": {
type: String,
default: "all 0.5s ease",
},
barBorderRadius: {
"bar-border-radius": {
type: Number,
default: 8, // ~0.5em at default font size
},
@@ -92,19 +88,19 @@ export default {
type: String,
default: "",
},
textAlign: {
"text-align": {
type: String,
default: "center", // 'left', 'right'
},
textPosition: {
"text-position": {
type: String,
default: "inside", // 'bottom', 'top', 'middle', 'inside'
},
fontSize: {
"font-size": {
type: Number,
default: 13,
},
textFgColor: {
"text-fg-color": {
type: String,
default: "#000",
},
@@ -112,7 +108,7 @@ export default {
type: String,
default: 'default',
},
helpText: {
"help-text": {
type: String,
default: "",
},
@@ -137,7 +133,7 @@ export default {
displayed_text() {
// Use valText if provided (for hybrid mode text override), otherwise use val
const displayVal = this.valText !== null ? this.valText : this.val;
if (!isNumber(displayVal)) return displayVal;
// Cap % like the bar fill so nested-mount indexed size > partition total
+24 -23
View File
@@ -3,8 +3,8 @@
<!-- Search input section -->
<div class="search-input-container">
<!-- Close button visible when search is active -->
<button v-if="active" type="button" class="action" @click="close" :aria-label="t('general.close')"
:title="t('general.close')">
<button v-if="active" type="button" class="action" @click="close" :aria-label="$t('general.close')"
:title="$t('general.close')">
<i class="material-symbols">close</i>
</button>
<!-- Search icon when search is not active -->
@@ -12,13 +12,13 @@
<!-- Input field for search -->
<input id="search-input" type="text"
@keyup.exact="keyup" @input="onInput" ref="input" :autofocus="active" v-model.trim="value"
aria-label="search input" :placeholder="t('general.search', { suffix: '...' })" />
aria-label="search input" :placeholder="$t('general.search', { suffix: '...' })" />
<div v-if="eventTheme === 'halloween'" class="halloween-eyes" aria-hidden="true">
<div class="eye"><div class="pupil"></div></div>
<div class="eye right"><div class="pupil"></div></div>
</div>
</div>
<div v-show="active" id="results">
<div v-show="active" id="results" ref="result">
<div class="inputWrapper">
<ExpandDropdown
v-if="multipleSources"
@@ -27,13 +27,13 @@
transparent
:options="searchSourceOptions"
all-value="__all__"
:all-selected-label="t('general.all')"
:all-selected-label="$t('general.all')"
aria-label="search sources dropdown"
@update:model-value="updateSource"
/>
<!-- Formatted display of selected value -->
<div class="searchContext">{{ t("search.searchContext", { context: getContext }) }}</div>
<div class="searchContext">{{ $t("search.searchContext", { context: getContext }) }}</div>
</div>
<div id="result-list" ref="resultListEl">
@@ -48,8 +48,8 @@
<ToggleSwitch
class="item"
v-model="showPreviewImages"
:name="t('search.showPreviewImages')"
:description="t('search.showPreviewImagesDescription')"
:name="$t('search.showPreviewImages')"
:description="$t('search.showPreviewImagesDescription')"
/>
</template>
</SearchFilters>
@@ -90,6 +90,7 @@
:thumbnail-url="showPreviewImages ? getThumbnailUrl(s) : ''"
/>
<span class="text-container">
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
{{ basePath(s.path, s.type === "directory") }}{{ baseName(s.path) }}
</span>
<div class="filesize">{{ humanSize(s.size) }}</div>
@@ -104,7 +105,7 @@
class="button open-advanced-search-button"
@click.stop.prevent="openInAdvancedSearch"
>
{{ t("search.openInAdvancedSearch") }}
{{ $t("search.openInAdvancedSearch") }}
</button>
</div>
</div>
@@ -112,26 +113,26 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, useTemplateRef, watch, type Ref } from "vue";
import { useI18n } from "@/i18n/index.ts";
import { useListingContextMenu } from "@/composables/useListingContextMenu.ts";
import { useSearchFilters } from "@/composables/useSearchFilters.ts";
import { useSearchRequest } from "@/composables/useSearchRequest.ts";
import { resourcesApi, toolsApi } from "@/api/index.js";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch, type Ref } from "vue";
import { useI18n } from "vue-i18n";
import { useListingContextMenu } from "@/composables/useListingContextMenu";
import { useSearchFilters } from "@/composables/useSearchFilters";
import { useSearchRequest } from "@/composables/useSearchRequest";
import { resourcesApi, toolsApi } from "@/api";
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
import Icon from "@/components/files/Icon.vue";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import SearchFilters from "@/components/tools/SearchFilters.vue";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import { router } from "@/router/index.ts";
import { getters, mutations, state } from "@/store/index.ts";
import type { FileListItem } from "@/store/types.ts";
import { url } from "@/utils/index.ts";
import { globalVars } from "@/utils/constants.ts";
import { router } from "@/router";
import { getters, mutations, state } from "@/store";
import type { FileListItem } from "@/store/types";
import { url } from "@/utils/";
import { globalVars } from "@/utils/constants";
import { debounce } from "@/utils/debounce.js";
import { sortedItems } from "@/utils/sort.ts";
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { isTypeAheadSessionActive } from "@/utils/listingTypeAhead.js";
import { shouldIgnoreOutsideTap } from "@/utils/tooltipHelp.js";
@@ -149,8 +150,8 @@ interface SearchResult {
const { t } = useI18n();
const resultListEl = useTemplateRef<HTMLElement>("resultListEl");
const input = useTemplateRef<HTMLInputElement>("input");
const resultListEl = ref<HTMLElement | null>(null);
const input = ref<HTMLInputElement | null>(null);
const noneMessage = ref(t("search.typeToSearch", { minSearchLength: globalVars.minSearchLength }));
const showPreviewImages = ref(false);
const advancedOptionsExpanded = ref(false);
+2 -2
View File
@@ -23,8 +23,8 @@
import Breadcrumbs from "@/components/files/Breadcrumbs.vue";
import ListingHeader from "@/components/files/ListingHeader.vue";
import DuplicateFinderActions from "@/components/tools/DuplicateFinderActions.vue";
import { getters, state } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import { getters, state } from "@/store";
import { eventBus } from "@/store/eventBus";
export default {
name: "Shelf",
+3 -4
View File
@@ -36,8 +36,7 @@
max="24"
step="1"
/>
<!-- i18n-ignore -->
<span class="size-label">{{ editorFontSize }}px</span>
<span class="size-label">{{ editorFontSize }}px</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
</div>
</div>
@@ -45,8 +44,8 @@
</template>
<script>
import { getters, mutations, state } from "@/store/index.ts";
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
import { getters, mutations, state } from "@/store";
import { getHumanReadableFilesize } from "@/utils/filesizes";
export default {
name: "StatusBar",
+2 -1
View File
@@ -12,13 +12,14 @@
</template>
<script>
import { getIconClass } from "@/utils/material-symbols.js";
import { getIconClass } from "@/utils/material-symbols";
export default {
name: "Toast",
props: {
toasts: {
type: Array,
required: true,
default: () => [],
},
},
+3 -3
View File
@@ -19,16 +19,16 @@
</template>
<script setup lang="ts">
import { computed, nextTick, ref, useTemplateRef, watch } from "vue";
import { computed, nextTick, ref, watch } from "vue";
import type { CSSProperties } from "vue";
import { useRoute } from "vue-router";
import { state } from "@/store/index.ts";
import { state } from "@/store";
import { hideInteractiveTooltip } from "@/utils/tooltipHelp.js";
const margin = 15;
const adjustedX = ref(0);
const adjustedY = ref(0);
const tooltipRef = useTemplateRef<HTMLElement>("tooltipRef");
const tooltipRef = ref<HTMLElement | null>(null);
const route = useRoute();
const tooltip = computed(() => state.tooltip);
+19 -23
View File
@@ -18,7 +18,7 @@
position-mode="absolute"
:edge-offset="{ top: '0.5em', right: '0.5em' }"
:auto-hide="false"
@press="lyricsScrollLocked = !lyricsScrollLocked"
@click="lyricsScrollLocked = !lyricsScrollLocked"
:label="lyricsLockToggleLabel"
/>
<!-- Scrollable area -->
@@ -61,7 +61,7 @@
position-mode="absolute"
:edge-offset="{ top: '0.5em', right: '0.5em' }"
:auto-hide="false"
@press="showVisualizerSettings"
@click="showVisualizerSettings"
:label="visualizerSettingsLabel"
/>
<canvas ref="visualizerCanvas" class="visualizer-canvas"></canvas>
@@ -74,7 +74,7 @@
import SlidingTabs from "@/components/settings/SlidingTabs.vue";
import PlaybackQueue from "@/components/prompts/PlaybackQueue.vue";
import FloatingActionButton from "@/components/settings/FloatingActionButton.vue";
import { mutations, state } from "@/store/index.ts";
import { mutations, state } from "@/store";
import { visualizerConfig } from "@/utils/visualizerConfig.js";
const LAST_TAB_KEY = 'plyrSidePanelActiveTab';
@@ -111,10 +111,7 @@ export default {
audioContext: { type: Object, default: null },
audioSource: { type: Object, default: null }, // MediaElementAudioSourceNode, see https://developer.mozilla.org/en-US/docs/Web/API/MediaElementAudioSourceNode
},
emits: {
seek: (timestamp) => typeof timestamp === "number",
"needs-audio-graph": () => true,
},
emits: ["seek", "needs-audio-graph"],
data() {
return {
activeTab: (() => {
@@ -122,7 +119,6 @@ export default {
return stored && ['queue', 'lyrics', 'visualizer'].includes(stored) ? stored : 'queue';
})(),
lyricsScrollLocked: false,
resizeObserver: null,
// Visualizer state
visualizerAnalyserLeft: null,
visualizerAnalyserRight: null,
@@ -248,8 +244,19 @@ export default {
this.resizeObserver = new ResizeObserver(() => {
if (this.activeTab === 'visualizer' && this.visualizerAnalyserLeft) this.resizeVisualizer();
});
window.addEventListener('resize', this.onWindowResize);
document.addEventListener('visibilitychange', this.onVisibilityChange);
this.windowResizeHandler = () => {
if (this.activeTab === 'visualizer' && this.visualizerAnalyserLeft) this.resizeVisualizer();
};
window.addEventListener('resize', this.windowResizeHandler);
this.visibilityChangeHandler = () => {
if (this.activeTab !== 'visualizer') return;
if (document.hidden) {
this.stopVisualizer();
} else {
this.startVisualizer();
}
};
document.addEventListener('visibilitychange', this.visibilityChangeHandler);
await this.$nextTick();
const container = this.$el?.querySelector('.tab-visualizer');
if (container) this.resizeObserver.observe(container);
@@ -257,23 +264,12 @@ export default {
beforeUnmount() {
document.removeEventListener('keydown', this.onKeyDown);
this.resizeObserver?.disconnect();
window.removeEventListener('resize', this.onWindowResize);
document.removeEventListener('visibilitychange', this.onVisibilityChange);
window.removeEventListener('resize', this.windowResizeHandler);
document.removeEventListener('visibilitychange', this.visibilityChangeHandler);
this.stopVisualizer();
this.fullCleanup();
},
methods: {
onWindowResize() {
if (this.activeTab === 'visualizer' && this.visualizerAnalyserLeft) this.resizeVisualizer();
},
onVisibilityChange() {
if (this.activeTab !== 'visualizer') return;
if (document.hidden) {
this.stopVisualizer();
} else {
this.startVisualizer();
}
},
scrollToActiveLine() {
if (this.lyricsScrollLocked) return;
const el = this.$refs.lyricsScrollable;
+7 -12
View File
@@ -32,13 +32,13 @@
</template>
<script>
import { resourcesApi } from "@/api/index.js";
import { notify } from "@/notify/index.ts";
import { getters, mutations, state } from "@/store/index.ts";
import { url } from "@/utils/index.ts";
import { resourcesApi } from "@/api";
import { notify } from "@/notify";
import { getters, mutations, state } from "@/store";
import { url } from "@/utils";
import { getObjectProperty } from '@/utils/object.js';
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
import { notifyMoveCopyFailure } from "@/utils/appNotifications.js";
import { goToItemNotificationButton } from "@/utils/notificationActions";
import { notifyMoveCopyFailure } from "@/utils/appNotifications";
export default {
name: "breadcrumbs",
@@ -48,12 +48,7 @@ export default {
dragOverItem: null, // Track which breadcrumb is being dragged over
};
},
props: {
noLink: {
type: Boolean,
default: false,
},
},
props: ["noLink"],
mounted() {
this.updatePaths();
window.addEventListener('dragend', this.clearDragState);
@@ -104,8 +104,8 @@
ref="extraMenuTrigger"
type="button"
class="editor-toolbar-btn"
:title="t('editor.md.moreOptions')"
:aria-label="t('editor.md.moreOptions')"
:title="$t('editor.md.moreOptions')"
:aria-label="$t('editor.md.moreOptions')"
@mousedown.prevent
@click="toggleMenu('extra')"
>
@@ -134,16 +134,16 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, useTemplateRef, watch } from "vue";
import { useI18n } from "@/i18n/index.ts";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import ace from "ace-builds";
import type { Ace } from "ace-builds";
import { mutations, state, getters } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import { mutations, state, getters } from "@/store";
import { eventBus } from "@/store/eventBus";
import { removeLastDir } from "@/utils/url.js";
import { popEnter, popLeave } from "@/utils/transitions.ts";
import { copyToClipboard } from "@/utils/clipboard.js";
import { notify } from "@/notify/index.ts";
import { notify } from "@/notify";
interface AnchorRange {
start: Ace.Anchor;
@@ -221,8 +221,8 @@ const lastColors = reactive(new Map<string, string>([
const colorInputRefs = new Map<string, HTMLInputElement>();
const iconMenuTriggerEls = new Map<"align" | "clipboard", HTMLElement>();
const iconMenuEls = new Map<"align" | "clipboard", HTMLElement>();
const extraMenuTrigger = useTemplateRef<HTMLElement>("extraMenuTrigger");
const extraMenu = useTemplateRef<HTMLElement>("extraMenu");
const extraMenuTrigger = ref<HTMLElement | null>(null);
const extraMenu = ref<HTMLElement | null>(null);
const toolbarButtons = computed((): ToolbarButton[] => {
// alwaysAvailable are for buttons that will be available for all filetypes, not just markdown.
+11 -13
View File
@@ -6,6 +6,7 @@
v-if="thumbnailSrc && !fullImageLoaded && !isTiff"
:src="thumbnailSrc"
class="image-ex-img"
ref="thumbnail"
aria-hidden="true"
/>
@@ -37,13 +38,13 @@
<script>
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import { notify } from "@/notify/index.ts";
import { getters, mutations, state } from "@/store/index.ts";
import { notify } from "@/notify";
import { getters, mutations, state } from '@/store';
import { acquireBestCachedThumbnailBlob, getBestCachedImage, releaseThumbnailBlob } from "@/utils/cache/imageCache.ts";
import { isRawImageMimeType } from "@/utils/mimetype.js";
import { resourcesApi } from "@/api/index.js";
import { isRawImageMimeType } from "@/utils/mimetype";
import { resourcesApi } from "@/api";
import { refreshViewToken, requestViewIdentity } from "@/api/viewToken.js";
import throttle from "@/utils/throttle.js";
import throttle from "@/utils/throttle";
export default {
components: {
@@ -55,14 +56,8 @@ export default {
default: null,
},
},
emits: {
"navigate-previous": () => true,
"navigate-next": () => true,
"close-preview": () => true,
},
data() {
return {
thumbBlobKey: null, // only read in lifecycle/sync code, never during render
moveDisabledTime: 200,
classList: [],
zoomStep: 0.25,
@@ -218,6 +213,9 @@ export default {
return this.edgeDy >= this.edgeCommitY && ay >= ax;
},
},
beforeCreate() {
this.thumbBlobKey = null;
},
created() {
this.syncThumbnailBlob();
},
@@ -527,7 +525,7 @@ export default {
this.showDismissHint = false;
} else {
this.dragOffsetX = 0;
const downward = Math.max(this.edgeDy, 0);
const downward = this.edgeDy > 0 ? this.edgeDy : 0;
this.dragOffsetY = this.rubberband(downward, this.edgeRubberMax);
this.showDismissHint = this.edgeDy >= this.edgeHintPx;
this.showNavHint = false;
@@ -551,7 +549,7 @@ export default {
this.showDismissHint = false;
} else {
this.dragOffsetX = 0;
const downward = Math.max(this.edgeDy, 0);
const downward = this.edgeDy > 0 ? this.edgeDy : 0;
this.dragOffsetY = this.rubberband(downward, this.edgeRubberMax);
this.showDismissHint = this.edgeDy >= this.edgeHintPx;
this.showNavHint = false;
+12 -19
View File
@@ -3,21 +3,21 @@
<!-- Source Selection Dropdown (hidden when browsing a fixed source, e.g. user scope row) -->
<div v-if="!hidePathChrome && showSourceSelector" class="source-selector" style="margin-bottom: 1rem;">
<label for="destinationSource" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
{{ t("prompts.destinationSource") }}
{{ $t("prompts.destinationSource") }}
</label>
<ExpandDropdown
input-id="destinationSource"
v-model="currentSource"
:options="sourceOptions"
:aria-label="t('prompts.destinationSource')"
:aria-label="$t('prompts.destinationSource')"
/>
</div>
<!-- Sortable Column Header (opt-in via sortable prop, e.g. destination pickers) -->
<div v-if="(!hidePathChrome && !fileList) || sortable || $slots.sticky" class="sticky-header" :class="{ 'header-hidden': headerHidden }">
<div v-if="(!hidePathChrome && !fileList) || sortable || $slots.pinned" class="sticky-header" :class="{ 'header-hidden': headerHidden }">
<!-- Current Path Display -->
<div v-if="!hidePathChrome && !fileList" aria-label="filelist-path" class="searchContext button clickable">
{{ t('general.path', { suffix: ':' }) }} {{ sourcePath.path }}
{{ $t('general.path', { suffix: ':' }) }} {{ sourcePath.path }}
</div>
<slot name="sticky" />
<ListingHeader v-if="sortable && !loading" :use-picker-sorting="!fileList" :use-navigation-sorting="!!fileList" />
@@ -68,25 +68,24 @@
</div>
<h2 v-if="filterQuery && !visibleItems.some((item) => item.name !== '..')" class="no-results">
<i class="material-symbols-outlined">search_off</i>
<span>{{ t("tools.advancedSearch.noResults") }}</span>
<span>{{ $t("tools.advancedSearch.noResults") }}</span>
</h2>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from "vue";
import { state, mutations, getters } from "@/store/index.ts";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { state, mutations, getters } from "@/store";
import type { FileListItem, MediaMetadata } from "@/store/types.ts";
import { url } from "@/utils/index.ts";
import { resourcesApi } from "@/api/index.js";
import { url } from "@/utils";
import { resourcesApi } from "@/api";
import ListingHeader from "@/components/files/ListingHeader.vue";
import ListingItem from "@/components/files/ListingItem.vue";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import { sortedItems } from "@/utils/sort.ts";
import { findScrollParents, findScroller, useListingVirtualization } from "@/composables/useListingVirtualization.ts";
import { useI18n } from "@/i18n/index.ts";
type ItemInfo = FileListItem & { isDir?: boolean; hasDuration?: boolean };
@@ -162,14 +161,8 @@ const emit = defineEmits<{
"update:filterQuery": [value: string];
}>();
defineSlots<{
sticky?(): unknown;
}>();
const { t } = useI18n();
const root = useTemplateRef<HTMLElement>("root");
const list = useTemplateRef<HTMLElement>("list");
const root = ref<HTMLElement | null>(null);
const list = ref<HTMLElement | null>(null);
const fallbackModified = new Date().toISOString();
const initialSource = props.browseSource || state.req.source;
@@ -380,7 +373,7 @@ async function withLoading(operation: () => Promise<unknown>) {
} finally {
const elapsed = Date.now() - startTime;
const remaining = Math.max(0, 200 - elapsed);
await new Promise((resolve) => { setTimeout(resolve, remaining); });
await new Promise((resolve) => setTimeout(resolve, remaining));
loading.value = false;
}
}
+13 -13
View File
@@ -7,7 +7,7 @@
<!-- Error state -->
<div v-if="error" class="tree-error">
<i class="material-symbols">error</i>
<span>{{ t("prompts.error") }}</span>
<span>{{ $t("prompts.error") }}</span>
</div>
<div v-if="rootNodes.length" ref="listEl" class="tree-list">
<div data-virtual-group="rows" :style="virtual.group('rows').style">
@@ -60,7 +60,7 @@
</a>
<div v-else-if="row.kind === 'empty'" class="tree-empty-folder">
<i class="material-symbols-outlined">sentiment_dissatisfied</i>
<span>{{ t('files.lonely') }}</span>
<span>{{ $t('files.lonely') }}</span>
</div>
<div v-else-if="row.kind === 'loading'" class="tree-loading">
<LoadingSpinner size="small" mode="placeholder" />
@@ -74,24 +74,24 @@
<!-- Empty state -->
<div v-if="rootNodes.length === 0 && !loading && !error" class="tree-empty">
<i class="material-symbols-outlined">sentiment_dissatisfied</i>
{{ t('files.lonely') }}
{{ $t('files.lonely') }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
import { resourcesApi } from "@/api/index.js";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { resourcesApi } from '@/api';
import Icon from '@/components/files/Icon.vue';
import LoadingSpinner from '@/components/LoadingSpinner.vue';
import i18n from '@/i18n/index.ts';
import { state, getters, mutations } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import { state, getters, mutations } from '@/store';
import { eventBus } from '@/store/eventBus';
import type { FileListItem } from '@/store/types.ts';
import { buildItemUrl, goToItem, joinPath } from "@/utils/url.js";
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
import { notify } from "@/notify/index.ts";
import { notifyMoveCopyFailure } from "@/utils/appNotifications.js";
import { buildItemUrl, goToItem, joinPath } from '@/utils/url';
import { goToItemNotificationButton } from '@/utils/notificationActions';
import { notify } from '@/notify';
import { notifyMoveCopyFailure } from '@/utils/appNotifications';
import { getObjectProperty } from '@/utils/object.js';
import { sortedItems } from '@/utils/sort.ts';
import { findScrollParents, useListingVirtualization } from '@/composables/useListingVirtualization.ts';
@@ -162,8 +162,8 @@ const props = withDefaults(
const t = i18n.global.t.bind(i18n.global);
const treeEl = useTemplateRef<HTMLElement>("treeEl");
const listEl = useTemplateRef<HTMLElement>("listEl");
const treeEl = ref<HTMLElement | null>(null);
const listEl = ref<HTMLElement | null>(null);
const rootNodes = ref<TreeNode[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
+9 -9
View File
@@ -18,12 +18,12 @@
:fbdata="threeDFbdata"
:is-thumbnail="true"
:add-load-delay="true"
@load-error="handle3DError" />
@error="handle3DError" />
<TextPreview v-else-if="shouldUseTextPreview"
:url="textPreviewUrl"
:language="textPreviewLanguage"
:modified="modified"
@load-error="handleTextPreviewError" />
@error="handleTextPreviewError" />
</span>
<!-- Regular material icon (no preview) -->
<span v-else ref="tileEl" class="image-preview" @mouseenter="hasTextPreview && handleMouseEnter()" @mouseleave="hasTextPreview && handleMouseLeave()">
@@ -40,11 +40,11 @@
</template>
<script setup lang="ts">
import { computed, inject, onBeforeUnmount, onMounted, ref, unref, useTemplateRef, watch, type Ref } from "vue";
import { fetchPreviewImage } from "@/api/resources.js";
import { computed, inject, onBeforeUnmount, onMounted, ref, unref, watch, type Ref } from "vue";
import { fetchPreviewImage } from "@/api/resources";
import { createAsyncComponent } from "@/utils/asyncComponent.js";
import { getTypeInfo } from "@/utils/mimetype.js";
import { getters, mutations, state } from "@/store/index.ts";
import { getTypeInfo } from "@/utils/mimetype";
import { getters, mutations, state } from "@/store";
import {
acquireThumbnailBlob,
getThumbnailBlobKey,
@@ -234,7 +234,7 @@ const textPreviewLanguage = computed(() => languageFromPath(props.filename));
// Measured tile width; only observed for text-previewable files so image tiles pay nothing.
const tileWidth = ref(0);
const tileEl = useTemplateRef<HTMLElement>("tileEl");
const tileEl = ref<HTMLElement | null>(null);
let tileResizeObserver: ResizeObserver | null = null;
function observeTileWidth(el: HTMLElement | null): void {
@@ -319,7 +319,7 @@ function clearPopupHoverTimeout(): void {
function cancelPreloadRequests(): void {
clearPopupHoverTimeout();
for (const timeoutId of previewTimeouts) clearTimeout(timeoutId);
previewTimeouts.forEach(clearTimeout);
previewTimeouts = [];
for (const controller of preloadAbortControllers) {
controller.abort();
@@ -483,7 +483,7 @@ function showPopupPreview(): void {
const updateThumbnail = () => {
if (!state.popupPreviewSourceInfo || state.popupPreviewSourceInfo.path !== props.path) {
for (const timeoutId of previewTimeouts) clearTimeout(timeoutId);
previewTimeouts.forEach(clearTimeout);
previewTimeouts = [];
return;
}
+2 -2
View File
@@ -10,8 +10,8 @@
</template>
<script>
import { state } from "@/store/index.ts";
import { fromNow } from "@/utils/moment.ts";
import { state } from "@/store";
import { fromNow } from "@/utils/moment";
import SettingsTable from "@/components/settings/Table.vue";
export default {
@@ -7,6 +7,7 @@
:aria-label="open ? $t('general.cancel') : $t('general.search')"
:title="open ? $t('general.cancel') : $t('general.search')"
>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<i class="material-symbols">{{ open ? "close" : "search" }}</i>
</button>
<input
@@ -36,9 +37,7 @@ export default {
default: false,
},
},
emits: {
"update:modelValue": (value) => typeof value === "string",
},
emits: ["update:modelValue"],
data() {
return {
open: false,
@@ -97,7 +97,7 @@
</template>
<script>
import { state, getters, mutations } from "@/store/index.ts";
import { state, getters, mutations } from "@/store";
export default {
name: "ListingHeader",
+44 -44
View File
@@ -53,7 +53,7 @@
class="material-symbols overlay-icon overlay-icon--bottom clickable"
aria-label="Download"
@click.stop.prevent="downloadFile"
>file_download</button>
>file_download</button> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="text">
@@ -93,23 +93,23 @@
</template>
<script>
import { computed, provide, shallowRef } from "vue";
import { globalVars } from "@/utils/constants.ts";
import downloadFiles from "@/utils/download.js";
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
import { getKindKey } from "@/utils/mimetype.js";
import { isTextPreviewableType } from "@/utils/textPreview.ts";
import { computed, shallowRef } from "vue";
import { globalVars } from "@/utils/constants";
import downloadFiles from "@/utils/download";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { getKindKey } from "@/utils/mimetype";
import { isTextPreviewableType } from "@/utils/textPreview";
import { hasCachedThumbnailBlob } from "@/utils/cache/imageCache.ts";
import { formatDuration } from "@/utils/files.js";
import { getObjectProperty } from '@/utils/object.js';
import { resourcesApi } from "@/api/index.js";
import { checkConflict } from "@/utils/upload.ts";
import { state, getters, mutations } from "@/store/index.ts"; // Import your custom store
import { url } from "@/utils/index.ts";
import { formatTimestamp, fromNow } from "@/utils/moment.ts";
import { notify } from "@/notify/index.ts";
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
import { notifyMoveCopyFailure } from "@/utils/appNotifications.js";
import { resourcesApi } from "@/api";
import { checkConflict } from "@/utils/upload";
import { state, getters, mutations } from "@/store"; // Import your custom store
import { url } from "@/utils";
import { formatTimestamp, fromNow } from "@/utils/moment";
import { notify } from "@/notify";
import { goToItemNotificationButton } from "@/utils/notificationActions";
import { notifyMoveCopyFailure } from "@/utils/appNotifications";
import Icon from "@/components/files/Icon.vue";
const selectedIndexSet = computed(() => {
@@ -204,29 +204,25 @@ export default {
localSelected: false,
};
},
emits: {
"item-select": (payload) => payload != null && "index" in payload,
"select-range": (payload) => payload != null && "startIndex" in payload && "endIndex" in payload,
beforeCreate() {
// runs before provide(), non-reactive holder on purpose
this.viewportRef = shallowRef(false);
},
setup() {
// The Icons inside this item inject it with Icon.vue
// we're using setup for now temporarily as is more easy than convert the whole component to script setup.
const viewportRef = shallowRef(false);
provide("inView", viewportRef);
return {
setInView(visible) {
viewportRef.value = visible;
},
touches: 0,
touchStartX: 0,
touchStartY: 0,
isLongPress: false,
isSwipe: false,
contextTimeout: null,
observedEl: null,
observer: null,
isUnmounted: false,
};
provide() {
// the Icons inside this item inject it, see Icon.vue
return { inView: this.viewportRef };
},
created() {
// non-reactive on purpose, they're never read by the template
this.touches = 0;
this.touchStartX = 0;
this.touchStartY = 0;
this.isLongPress = false;
this.isSwipe = false;
this.contextTimeout = null;
this.observedEl = null;
this.observer = null;
this.isUnmounted = false;
},
props: {
name: String,
@@ -495,7 +491,7 @@ export default {
if (this.$el && this.$el instanceof Element) {
this.observedEl = this.$el;
viewportCallbacks.set(this.$el, (visible) => {
this.setInView(visible);
this.viewportRef.value = visible;
if (visible) {
this.isThumbnailInView = true;
}
@@ -520,7 +516,7 @@ export default {
downloadFiles(state.selected);
} else {
// Emit selection event for local handling
this.$emit('item-select', { index: this.index, selected: true });
this.$emit('select', { index: this.index, selected: true });
}
},
/** @param {TouchEvent} event */
@@ -618,7 +614,7 @@ export default {
} else {
// Emit selection event for local handling
if (!this.localSelected) {
this.$emit('item-select', { index: this.index, selected: true });
this.$emit('select', { index: this.index, selected: true });
}
}
if (event.dataTransfer) {
@@ -691,7 +687,11 @@ export default {
const destinationDir = this.path;
// If destination dir is the same as or contains the source path, skip
return !(fromPath === destinationDir || fromPath.startsWith(`${destinationDir}/`));
if (fromPath === destinationDir || fromPath.startsWith(`${destinationDir}/`)) {
return false;
}
return true;
});
// If all items were filtered out, silently skip the operation
@@ -805,7 +805,7 @@ export default {
mutations.resetSelected();
mutations.addSelected(this.index);
} else {
this.$emit('item-select', { index: this.index, selected: true });
this.$emit('select', { index: this.index, selected: true });
}
}
}
@@ -899,7 +899,7 @@ export default {
// Local selection handling - emit events instead of updating global state
if (event.shiftKey) {
// Shift-click: select range (emit event for parent to handle)
this.$emit('select-range', {
this.$emit('selectRange', {
startIndex: state.lastSelectedIndex !== null ? state.lastSelectedIndex : this.index,
endIndex: this.index
});
@@ -908,7 +908,7 @@ export default {
// Always toggle - parent component will handle the toggle logic
// Just emit the index, parent will check current state and toggle
this.$emit('item-select', { index: this.index });
this.$emit('select', { index: this.index });
}
},
open() {
@@ -20,19 +20,18 @@
v-if="showSplitViewToggle"
icon="vertical_split"
:label="splitViewActionLabel"
@press="toggleSplitView"
@click="toggleSplitView"
/>
</template>
<script lang="ts">
import type { PropType } from "vue";
import type { Ace } from "ace-builds";
import i18n from "@/i18n/index.ts";
import { state, getters, mutations } from "@/store/index.ts";
import { state, getters, mutations } from "@/store";
import { createAsyncComponent } from "@/utils/asyncComponent.js";
import Scrollbar from "@/components/files/Scrollbar.vue";
import FloatingActionButton from "@/components/settings/FloatingActionButton.vue";
import { createScrollSyncGuard } from "@/utils/markdownScrollSync.ts";
import { createScrollSyncGuard } from "@/utils/markdownScrollSync";
const MD_SPLIT_PERCENT_KEY = "mdSplitPercent";
const LIVE_CONTENT_INTERVAL_MS = 185; // Throttle preview updates to avoid performance issues
@@ -63,9 +62,7 @@ export default {
default: null,
},
},
emits: {
resize: (percent: number) => typeof percent === "number",
},
emits: ["resize"],
data: () => ({
liveMarkdownContent: "", // editor current buffer
liveContentTimer: null as ReturnType<typeof setTimeout> | null,
@@ -115,7 +112,7 @@ export default {
return getters.showSplitViewToggle();
},
splitViewActionLabel() {
return getters.isSplitViewActive() ? i18n.global.t("editor.exitSplitView") : i18n.global.t("editor.splitView");
return getters.isSplitViewActive() ? this.$t("editor.exitSplitView") : this.$t("editor.splitView");
},
},
methods: {
@@ -13,8 +13,8 @@
</template>
<script>
import { mutations, state } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import { mutations, state } from "@/store";
import { eventBus } from "@/store/eventBus";
function normalizeContextId() {
return `path-picker-btn-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
@@ -67,11 +67,7 @@ export default {
},
},
emits: {
"update:path": (path) => typeof path === "string",
"update:source": (source) => typeof source === "string",
navigate: () => true,
},
emits: ["update:path", "update:source", "navigate"],
data() {
return {
@@ -13,7 +13,7 @@
:key="sourceInfo.path"
:url="sourceInfo.textUrl"
:modified="sourceInfo.modified"
@load-error="onTextError"
@error="onTextError"
/>
</div>
<img v-else-if="sourceInfo?.url" :src="sourceInfo.url" alt="Popup img" @load="onImageLoad" />
@@ -22,7 +22,7 @@
<script>
import { createAsyncComponent } from "@/utils/asyncComponent.js";
import { state, getters, mutations } from "@/store/index.ts";
import { state, getters, mutations } from "@/store";
import { setImageLoaded } from "@/utils/cache/imageCache.ts";
export default {
@@ -37,11 +37,14 @@ export default {
top: "0px",
left: "0px",
},
cursorX: 0,
cursorY: 0,
rafId: null,
};
},
beforeCreate() {
// Plain fields on purpose cuz nothing in the template reads them
this.cursorX = 0;
this.cursorY = 0;
this.rafId = null;
},
watch: {
async sourceInfo(newVal) {
if (newVal) {
+5 -4
View File
@@ -32,7 +32,7 @@
</template>
<script>
import { state, mutations, getters } from "@/store/index.ts";
import { state, mutations, getters } from "@/store";
import { eventBus } from "@/store/eventBus.ts";
import { isHtmlMimeType } from "@/utils/mimetype.js";
@@ -63,11 +63,12 @@ export default {
canScroll: false,
resizeObserver: null,
mutationObserver: null,
wrapperObserver: null,
wrapperRect: null,
readyTimeout: null,
};
},
created() {
this.wrapperRect = null;
this.readyTimeout = null;
},
computed: {
isScrollable() {
return getters.isScrollable();
+3 -3
View File
@@ -30,9 +30,9 @@
</template>
<script>
import { state, getters } from "@/store/index.ts";
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
import { getTypeInfo } from "@/utils/mimetype.js";
import { state, getters } from "@/store";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { getTypeInfo } from "@/utils/mimetype";
export default {
name: "ShareInfo",
@@ -1,96 +1,96 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApp, h, nextTick } from "vue";
vi.mock("@/store", () => ({
getters: { isDarkMode: () => false },
state: { sessionId: "test-session" },
}));
import TextPreview from "./TextPreview.vue";
import { clearTextPreviewCache } from "@/utils/textPreview.ts";
function payload(overrides: Record<string, unknown> = {}) {
return {
content: "",
kind: "plain",
format: "text",
truncated: false,
size: 1,
...overrides,
};
}
function stubFetch(data: unknown) {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify(data), { status: 200 })),
);
}
async function flush() {
// Wait for the fetch promise chain and queued limiter to settle.
await new Promise((resolve) => { setTimeout(resolve, 0); });
await nextTick();
await nextTick();
}
function mount(props: { url: string; modified?: string | null; language?: string | null }) {
const el = document.createElement("div");
document.body.appendChild(el);
const app = createApp({ render: () => h(TextPreview, props) });
app.mount(el);
return { el, app };
}
describe("TextPreview rendering", () => {
afterEach(() => {
clearTextPreviewCache();
vi.unstubAllGlobals();
document.body.innerHTML = "";
});
it("does not render a truncation fade for a truncated payload", async () => {
stubFetch(payload({ content: "<p>hello world</p>", kind: "html", format: "html", truncated: true, size: 99_999 }));
const { el, app } = mount({ url: "/api/resources/preview/text?path=%2Fa.html", modified: "1" });
await flush();
expect(el.querySelector(".text-preview-html")?.textContent).toContain("hello world");
expect(el.querySelector(".text-preview-fade")).toBeNull();
app.unmount();
});
it("uses the language prop to pick the CSV delimiter", async () => {
stubFetch(payload({ content: "a\tb\n1\t2", kind: "csv" }));
const { el, app } = mount({
url: "/api/resources/preview/text?path=%2Fdata.tsv",
modified: "1",
language: "tsv",
});
await flush();
const cells = el.querySelectorAll(".text-preview-table thead th");
expect(cells).toHaveLength(2);
expect(cells[0]?.textContent).toBe("a");
expect(cells[1]?.textContent).toBe("b");
app.unmount();
});
it("reuses the rendered HTML cache for the same url and modified time", async () => {
stubFetch(payload({ content: "<p>cached</p>", kind: "html", format: "html" }));
const props = { url: "/api/resources/preview/text?path=%2Fb.html", modified: "7" };
const first = mount(props);
await flush();
expect(first.el.querySelector(".text-preview-html")?.textContent).toContain("cached");
first.app.unmount();
// A second mount (as happens when the popup reopens) must not re-fetch.
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
fetchMock.mockClear();
const second = mount(props);
await flush();
expect(second.el.querySelector(".text-preview-html")?.textContent).toContain("cached");
expect(fetchMock).not.toHaveBeenCalled();
second.app.unmount();
});
});
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApp, h, nextTick } from "vue";
vi.mock("@/store", () => ({
getters: { isDarkMode: () => false },
state: { sessionId: "test-session" },
}));
import TextPreview from "./TextPreview.vue";
import { clearTextPreviewCache } from "@/utils/textPreview.ts";
function payload(overrides: Record<string, unknown> = {}) {
return {
content: "",
kind: "plain",
format: "text",
truncated: false,
size: 1,
...overrides,
};
}
function stubFetch(data: unknown) {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify(data), { status: 200 })),
);
}
async function flush() {
// Wait for the fetch promise chain and queued limiter to settle.
await new Promise((resolve) => setTimeout(resolve, 0));
await nextTick();
await nextTick();
}
function mount(props: { url: string; modified?: string | null; language?: string | null }) {
const el = document.createElement("div");
document.body.appendChild(el);
const app = createApp({ render: () => h(TextPreview, props) });
app.mount(el);
return { el, app };
}
describe("TextPreview rendering", () => {
afterEach(() => {
clearTextPreviewCache();
vi.unstubAllGlobals();
document.body.innerHTML = "";
});
it("does not render a truncation fade for a truncated payload", async () => {
stubFetch(payload({ content: "<p>hello world</p>", kind: "html", format: "html", truncated: true, size: 99_999 }));
const { el, app } = mount({ url: "/api/resources/preview/text?path=%2Fa.html", modified: "1" });
await flush();
expect(el.querySelector(".text-preview-html")?.textContent).toContain("hello world");
expect(el.querySelector(".text-preview-fade")).toBeNull();
app.unmount();
});
it("uses the language prop to pick the CSV delimiter", async () => {
stubFetch(payload({ content: "a\tb\n1\t2", kind: "csv" }));
const { el, app } = mount({
url: "/api/resources/preview/text?path=%2Fdata.tsv",
modified: "1",
language: "tsv",
});
await flush();
const cells = el.querySelectorAll(".text-preview-table thead th");
expect(cells).toHaveLength(2);
expect(cells[0]?.textContent).toBe("a");
expect(cells[1]?.textContent).toBe("b");
app.unmount();
});
it("reuses the rendered HTML cache for the same url and modified time", async () => {
stubFetch(payload({ content: "<p>cached</p>", kind: "html", format: "html" }));
const props = { url: "/api/resources/preview/text?path=%2Fb.html", modified: "7" };
const first = mount(props);
await flush();
expect(first.el.querySelector(".text-preview-html")?.textContent).toContain("cached");
first.app.unmount();
// A second mount (as happens when the popup reopens) must not re-fetch.
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
fetchMock.mockClear();
const second = mount(props);
await flush();
expect(second.el.querySelector(".text-preview-html")?.textContent).toContain("cached");
expect(fetchMock).not.toHaveBeenCalled();
second.app.unmount();
});
});
@@ -1,5 +1,5 @@
<template>
<div class="text-preview" :class="{ 'text-preview--dark': isDark }" aria-hidden="true">
<div ref="root" class="text-preview" :class="{ 'text-preview--dark': isDark }" aria-hidden="true">
<span v-if="status === 'loading'" class="material-symbols text-preview-placeholder">{{ PLACEHOLDER_ICON }}</span>
<div v-else-if="status === 'ready' && csvTable" class="text-preview-body text-preview-table-wrap">
<table class="text-preview-table">
@@ -15,9 +15,7 @@
</tbody>
</table>
</div>
<!-- biome-ignore lint/nursery/noVueVHtml: renderedHtml is sanitized -->
<div v-else-if="status === 'ready' && renderedHtml" class="text-preview-body text-preview-html" v-html="renderedHtml"></div>
<!-- biome-ignore lint/nursery/noVueVHtml: highlighted is sanitized -->
<pre v-else-if="status === 'ready' && highlighted !== null" class="text-preview-body text-preview-plain hljs" v-html="highlighted"></pre>
<pre v-else-if="status === 'ready' && payload" class="text-preview-body text-preview-plain">{{ payload.content }}</pre>
</div>
@@ -25,7 +23,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, shallowRef, watch } from "vue";
import { getters } from "@/store/index.ts";
import { getters } from "@/store";
import { applyHighlightTheme, loadHljs } from "@/utils/highlight.ts";
import { parseDelimited } from "@/utils/delimited.ts";
import {
@@ -51,7 +49,7 @@ const props = defineProps<{
language?: string | null;
}>();
const emit = defineEmits<(e: "load-error") => void>();
const emit = defineEmits<(e: "error") => void>();
// Only plain source files are syntax highlighted. Markdown and HTML are rendered instead.
const HIGHLIGHT_KINDS = new Set(["code", "css"]);
@@ -182,7 +180,7 @@ watch(
} catch {
if (current !== requestId) return;
status.value = "error";
emit("load-error");
emit("error");
}
},
{ immediate: true },
+29 -24
View File
@@ -19,6 +19,7 @@
<!-- Previous button -->
<button
v-if="enabled && hasPrevious"
ref="prevButton"
type="button"
:tabindex="showNav ? 0 : -1"
@click.prevent="handlePrevClick"
@@ -43,13 +44,14 @@
:title="$t('general.previous')"
>
<i class="material-symbols">
{{ dragState.type === 'previous' && dragState.atFullExtent ? 'list_alt' : 'chevron_left' }}
{{ dragState.type === 'previous' && dragState.atFullExtent ? 'list_alt' : 'chevron_left' }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</i>
</button>
<!-- Next button -->
<button
v-if="enabled && hasNext"
ref="nextButton"
type="button"
:tabindex="showNav ? 0 : -1"
@click.prevent="handleNextClick"
@@ -68,13 +70,14 @@
:title="$t('general.next')"
>
<i class="material-symbols">
{{ dragState.type === 'next' && dragState.atFullExtent ? 'list_alt' : 'chevron_right' }}
{{ dragState.type === 'next' && dragState.atFullExtent ? 'list_alt' : 'chevron_right' }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</i>
</button>
<!-- Close preview (same control as swipe-down / back) -->
<button
v-if="enabled && showPreviewCloseButton"
ref="closeButton"
type="button"
:tabindex="showCloseNavChrome ? 0 : -1"
@click.prevent="handleClosePreviewClick"
@@ -96,11 +99,11 @@
</template>
<script>
import { state, getters, mutations } from "@/store/index.ts";
import throttle from "@/utils/throttle.js";
import { previewViews } from "@/utils/constants.ts";
import { url } from "@/utils/index.ts";
import { resourcesApi } from "@/api/index.js";
import { state, getters, mutations } from "@/store";
import throttle from "@/utils/throttle";
import { previewViews } from "@/utils/constants";
import { url } from "@/utils";
import { resourcesApi } from "@/api";
import { navigatePlaybackQueue } from "@/utils/playbackQueue.js";
import {
isInLeftNavHoverZone,
@@ -124,7 +127,6 @@ export default {
threshold: 0, // Will be calculated as 10em in pixels
atFullExtent: false,
triggered: false,
wasDrag: false,
},
// State tracking
navigationTimeout: null,
@@ -218,12 +220,6 @@ export default {
nextRaw() {
return state.navigation.nextRaw;
},
navigationRequestKey() {
return state.req ? `${state.req.source ?? ""}\u0000${state.req.path ?? ""}` : "";
},
navigationParentItems() {
return state.req?.parentDirItems;
},
currentView() {
const view = getters.currentView();
return view;
@@ -285,6 +281,18 @@ export default {
await this.setupNavigationForCurrentItem();
}
},
'state.req': {
async handler() {
this.updateNavigationEnabled();
// Auto-setup navigation when request changes and we're enabled
if (this.enabled) {
await this.$nextTick();
await this.setupNavigationForCurrentItem();
}
},
deep: true,
immediate: false
},
async enabled(newEnabled) {
if (newEnabled && state.req) {
await this.$nextTick();
@@ -301,21 +309,18 @@ export default {
});
}, 100);
},
navigationRequestKey() {
if (this.enabled && state.req) {
void this.setupNavigationForCurrentItem();
}
},
navigationParentItems() {
if (this.enabled && state.req) {
void this.setupNavigationForCurrentItem();
}
},
navigationSortOverride() {
if (this.enabled && state.req) {
void this.setupNavigationForCurrentItem();
}
},
// Watch for when navigation links are set up
'state.navigation.previousLink'() {
this.showInitialNavigation();
},
'state.navigation.nextLink'() {
this.showInitialNavigation();
},
},
async mounted() {
window.addEventListener("keydown", this.keyEvent);
+10 -10
View File
@@ -38,7 +38,7 @@
<!-- Default behavior banner -->
<div class="card item">
<div class="card-content banner-content">
<i class="material-symbols-outlined">{{ sourceDenyDefault ? 'do_not_disturb_on' : 'check_circle' }}</i>
<i class="material-symbols-outlined">{{ sourceDenyDefault ? 'do_not_disturb_on' : 'check_circle' }}</i> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("access.defaultBehavior", { suffix: ":" }) }} {{ sourceDenyDefault ? $t("access.deny") : $t("access.allow")
}}
<HelpTooltipIcon :text="$t('access.defaultBehaviorDescription')" />
@@ -66,7 +66,7 @@
:icon="addType === 'group' ? 'group_add' : 'person_add'"
class="flat-left form-compact"
:exclude="existingNames"
@selected="addEntries"
@select="addEntries"
/>
<button v-else type="button" class="button form-button flat-left form-compact" @click="addEntries([])">
<i class="material-symbols-outlined">add</i>
@@ -115,9 +115,9 @@
</template>
<script>
import { notify } from "@/notify/index.ts";
import { mutations } from "@/store/index.ts";
import { accessApi } from "@/api/index.js";
import { notify } from "@/notify";
import { mutations } from "@/store";
import { accessApi } from "@/api";
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
import FileList from "../files/FileList.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
@@ -127,8 +127,8 @@ import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import ActivityViewerButton from "@/components/settings/ActivityViewerButton.vue";
import SettingsTable from "@/components/settings/Table.vue";
import EntityPickerButton from "@/components/settings/EntityPickerButton.vue";
import { activityViewerPresets } from "@/utils/activityViewerLink.js";
import { eventBus } from "@/store/eventBus.ts";
import { activityViewerPresets } from "@/utils/activityViewerLink";
import { eventBus } from "@/store/eventBus";
export default {
name: "access",
@@ -146,7 +146,7 @@ export default {
props: {
promptId: { type: [String, Number], default: null },
sourceName: { type: String, required: true },
path: { type: String, default: "/" },
path: { type: String, required: true, default: "/" },
embedded: { type: Boolean, default: false }
},
data() {
@@ -402,7 +402,7 @@ export default {
this.saving = true;
let failed = false;
try {
for (const op of this.pendingDeletes.slice()) {
for (const op of [...this.pendingDeletes]) {
await accessApi.del(this.currentSource, this.currentPath, {
allow: op.allow,
ruleCategory: op.ruleCategory,
@@ -411,7 +411,7 @@ export default {
});
this.pendingDeletes.splice(this.pendingDeletes.indexOf(op), 1);
}
for (const op of this.pendingAdds.slice()) {
for (const op of [...this.pendingAdds]) {
await accessApi.add(this.currentSource, this.currentPath, {
allow: op.allow,
ruleCategory: op.ruleCategory,
@@ -52,7 +52,7 @@
>
<span class="permission-name">{{ permission }}</span>
<span class="permission-status" :class="{ enabled: isEnabled, disabled: !isEnabled }">
{{ isEnabled ? '✓' : '✗' }}
{{ isEnabled ? '✓' : '✗' }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</span>
</div>
</div>
@@ -72,11 +72,11 @@
</template>
<script>
import { mutations } from "@/store/index.ts";
import { notify } from "@/notify/index.ts";
import { authApi } from "@/api/index.js";
import { eventBus } from "@/store/eventBus.ts";
import { copyToClipboard } from "@/utils/clipboard.js";
import { mutations } from "@/store";
import { notify } from "@/notify";
import { authApi } from "@/api";
import { eventBus } from "@/store/eventBus";
import { copyToClipboard } from "@/utils/clipboard";
export default {
name: "ActionApi",
@@ -25,13 +25,13 @@
<script>
import SettingsTable from "@/components/settings/Table.vue";
import { formatTimestamp } from "@/utils/moment.ts";
import { formatTimestamp } from "@/utils/moment";
import {
buildActivityDetailRows,
activityEventLabel,
activityRowPath,
activityRowSource,
} from "@/utils/activityDetails.js";
} from "@/utils/activityDetails";
export default {
name: "ActivityEventDetails",
@@ -4,7 +4,7 @@
<Editor
:viewer-mode="true"
:content="previewContent"
editor-mode="json"
:editor-mode="'json'"
:read-only="true"
/>
</div>
@@ -13,8 +13,8 @@
<script>
import { createAsyncComponent } from "@/utils/asyncComponent.js";
import { notify } from "@/notify/index.ts";
import { getAnalyticsPreview } from "@/api/settings.js";
import { notify } from "@/notify";
import { getAnalyticsPreview } from "@/api/settings";
export default {
name: "AnalyticsDiagnostic",
+4 -4
View File
@@ -142,10 +142,10 @@
</template>
<script>
import { state, mutations, getters } from "@/store/index.ts";
import { notify } from "@/notify/index.ts";
import { resourcesApi } from "@/api/index.js";
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
import { state, mutations, getters } from "@/store";
import { notify } from "@/notify";
import { resourcesApi } from "@/api";
import { goToItemNotificationButton } from "@/utils/notificationActions";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import FileList from "@/components/files/FileList.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
@@ -24,7 +24,7 @@
</template>
<script>
import { getters } from "@/store/index.ts";
import { getters } from "@/store";
export default {
name: "closeWithActiveUploads",
@@ -41,18 +41,18 @@ export default {
},
created() {
// Listen for ESC key to treat it as "Stay on Prompt"
this.escKeyHandler = (event) => {
if (event.keyCode === 27 && getters.currentPromptName() === "CloseWithActiveUploads") {
event.stopImmediatePropagation();
this.stayOnPrompt();
}
};
window.addEventListener("keydown", this.escKeyHandler, true);
},
beforeUnmount() {
window.removeEventListener("keydown", this.escKeyHandler, true);
},
methods: {
escKeyHandler(event) {
if (event.keyCode === 27 && getters.currentPromptName() === "CloseWithActiveUploads") {
event.stopImmediatePropagation();
this.stayOnPrompt();
}
},
stayOnPrompt() {
if (this.currentPrompt?.cancel) {
this.currentPrompt.cancel();
@@ -4,21 +4,22 @@
<ToggleSwitch
class="item"
v-model="showFull"
@update:model-value="fetchConfig"
@update:modelValue="fetchConfig"
:name="$t('settings.configViewerShowFull')"
/>
<ToggleSwitch
class="item"
v-model="showComments"
@update:model-value="fetchConfig"
@update:modelValue="fetchConfig"
:name="$t('settings.configViewerShowComments')"
/>
</div>
<div class="editor-container">
<Editor
:viewer-mode="true"
:content="configContent"
editor-mode="yaml"
:editor-mode="'yaml'"
:read-only="true"
/>
</div>
@@ -27,7 +28,7 @@
<script>
import { createAsyncComponent } from "@/utils/asyncComponent.js";
import { config } from "@/api/settings.js";
import { config } from "@/api/settings";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
export default {
@@ -44,8 +44,8 @@
</template>
<script>
import { mutations } from "@/store/index.ts";
import { notifyMoveCopyFailure } from "@/utils/appNotifications.js";
import { mutations } from "@/store";
import { notifyMoveCopyFailure } from "@/utils/appNotifications";
export default {
name: "copy-paste-confirm",
@@ -62,12 +62,12 @@
</template>
<script>
import { mutations } from "@/store/index.ts";
import { notify } from "@/notify/index.ts";
import { authApi } from "@/api/index.js";
import { mutations } from "@/store";
import { notify } from "@/notify";
import { authApi } from "@/api";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import { eventBus } from "@/store/eventBus.ts";
import { eventBus } from "@/store/eventBus";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
export default {
@@ -53,7 +53,7 @@
</template>
<script>
import { getters, mutations } from "@/store/index.ts";
import { getters, mutations } from "@/store";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
+18 -18
View File
@@ -3,14 +3,14 @@
<!-- Loading spinner overlay -->
<div v-show="deleting" class="loading-content">
<LoadingSpinner size="small" />
<p class="loading-text">{{ t("prompts.operationInProgress") }}</p>
<p class="loading-text">{{ $t("prompts.operationInProgress") }}</p>
</div>
<div v-show="!deleting">
<p class="card-message" v-if="itemsToDelete.length === 1">
{{ t("prompts.deleteMessageSingle") }}
{{ $t("prompts.deleteMessageSingle") }}
</p>
<p class="card-message" v-else>
{{ t("prompts.deleteMessageMultiple", { count: itemsToDelete.length }) }}
{{ $t("prompts.deleteMessageMultiple", { count: itemsToDelete.length }) }}
</p>
<div ref="list" class="delete-items-list" @contextmenu.capture.prevent.stop>
<div data-virtual-group="items" :style="virtual.group('items').style">
@@ -52,9 +52,9 @@
@click="closeTopPrompt"
class="button button--flat button--grey"
:disabled="deleting"
:aria-label="t('general.cancel')"
:title="t('general.cancel')">
{{ t("general.cancel") }}
:aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
{{ $t("general.cancel") }}
</button>
<button
type="button"
@@ -62,25 +62,25 @@
class="button button--flat button--red"
:disabled="deleting"
aria-label="Confirm-Delete"
:title="t('general.delete')">
{{ t("general.delete") }}
:title="$t('general.delete')">
{{ $t("general.delete") }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, useTemplateRef, watch } from "vue";
import { useI18n } from "@/i18n/index.ts";
import { resourcesApi } from "@/api/index.js";
import buttons from "@/utils/buttons.js";
import { state, getters, mutations } from "@/store/index.ts";
import type { FileListItem } from "@/store/types.ts";
import { notify } from "@/notify/index.ts";
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { resourcesApi } from "@/api";
import buttons from "@/utils/buttons";
import { state, getters, mutations } from "@/store";
import type { FileListItem } from "@/store/types";
import { notify } from "@/notify";
import ListingItem from "@/components/files/ListingItem.vue";
import { eventBus } from "@/store/eventBus.ts";
import { eventBus } from "@/store/eventBus";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import { useListingVirtualization } from "@/composables/useListingVirtualization.ts";
import url from "@/utils/url.js";
import url from "@/utils/url";
import { removeFromFolderLists } from "@/utils/cache/listingCache.ts";
export interface DeleteItem {
@@ -111,7 +111,7 @@ defineOptions({ name: "delete" });
const { t } = useI18n();
const list = useTemplateRef<HTMLElement>("list");
const list = ref<HTMLElement | null>(null);
const failedItems = ref<FailedItem[]>([]);
const deleting = ref(false);
+16 -16
View File
@@ -1,6 +1,6 @@
<template>
<div v-if="!hasDownloads && currentPrompt && currentPrompt.confirm" class="card-content">
<p>{{ t("prompts.downloadMessage") }}</p>
<p>{{ $t("prompts.downloadMessage") }}</p>
<button
type="button"
@@ -15,7 +15,7 @@
</button>
</div>
<div v-if="!hasDownloads && (!currentPrompt?.confirm)" class="card-content lonely-message">
<span>{{ t("files.lonely") }}</span>
<span>{{ $t("files.lonely") }}</span>
</div>
<div v-if="hasDownloads" class="card-content">
<div ref="list" class="download-list">
@@ -33,9 +33,9 @@
<p class="file-name">{{ download.name }}</p>
<ProgressBar
:val="download.status === 'completed'
? t('prompts.completed')
? $t('prompts.completed')
: download.status === 'error'
? t('prompts.error')
? $t('prompts.error')
: download.loaded
"
:unit="download.status === 'completed' || download.status === 'error' ? '' : 'bytes'"
@@ -51,15 +51,15 @@
v-if="download.status === 'error'"
@click="retryDownload(download.id)"
class="action"
:aria-label="t('general.retry')" :title="t('general.retry')"
:aria-label="$t('general.retry')" :title="$t('general.retry')"
>
<i class="material-symbols">replay</i>
</button>
<button
type="button"
@click="downloadManager.cancel(download.id)"
class="action" :aria-label="t('general.cancel')"
:title="t('general.cancel')"
class="action" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')"
>
<i class="material-symbols">close</i>
</button>
@@ -80,20 +80,20 @@
@click="downloadManager.clearCompleted()"
class="button button--flat"
:disabled="!hasClearable"
:aria-label="t('buttons.clearCompleted')" :title="t('buttons.clearCompleted')"
:aria-label="$t('buttons.clearCompleted')" :title="$t('buttons.clearCompleted')"
>
{{ t("buttons.clearCompleted") }}
{{ $t("buttons.clearCompleted") }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, useTemplateRef, watch } from "vue";
import { useI18n } from "@/i18n/index.ts";
import { getters } from "@/store/index.ts";
import { downloadManager } from "@/utils/downloadManager.ts";
import type { DownloadItem } from "@/utils/downloadManager.ts";
import { resourcesApi } from "@/api/index.js";
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { getters } from "@/store";
import { downloadManager } from "@/utils/downloadManager";
import type { DownloadItem } from "@/utils/downloadManager";
import { resourcesApi } from "@/api";
import ProgressBar from "@/components/ProgressBar.vue";
import { findScrollParents, useListingVirtualization } from "@/composables/useListingVirtualization.ts";
@@ -116,7 +116,7 @@ const vFocus = {
const formats = { zip: "zip", targz: "tar.gz" } as const;
const list = useTemplateRef<HTMLElement>("list");
const list = ref<HTMLElement | null>(null);
const currentPrompt = computed(() => getters.currentPrompt());
const downloads = computed(() => downloadManager.queue);
const hasDownloads = computed(() => downloads.value.length > 0);
@@ -45,26 +45,26 @@
type="button"
class="button button--flat button--grey"
@click="resetEditorConfig"
:aria-label="t('player.visualizer.resetDefaults')"
:title="t('player.visualizer.resetDefaults')"
> {{ t("player.visualizer.resetDefaults") }}
:aria-label="$t('player.visualizer.resetDefaults')"
:title="$t('player.visualizer.resetDefaults')"
> {{ $t("player.visualizer.resetDefaults") }}
</button>
<button
type="button"
class="button button--flat"
@click="closeTopPrompt"
:aria-label="t('general.ok')"
:title="t('general.ok')"
> {{ t("general.ok") }}
:aria-label="$t('general.ok')"
:title="$t('general.ok')"
> {{ $t("general.ok") }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "@/i18n/index.ts";
import { mutations } from "@/store/index.ts";
import { editorConfig, saveEditorConfig, resetEditorConfig, type EditorConfig } from "@/utils/editorConfig.ts";
import { useI18n } from "vue-i18n";
import { mutations } from "@/store";
import { editorConfig, saveEditorConfig, resetEditorConfig, type EditorConfig } from "@/utils/editorConfig";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
@@ -80,12 +80,9 @@ interface EditorSettingField {
options?: { value: string | number; label: string }[];
}
withDefaults(
defineProps<{
promptId?: string | number | null;
}>(),
{ promptId: null },
);
defineProps({
promptId: { type: [String, Number], default: null },
});
defineOptions({ name: "EditorSettings" });
@@ -38,8 +38,8 @@
</template>
<script>
import { mutations } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import { mutations } from "@/store";
import { eventBus } from "@/store/eventBus";
import EntityPickerList from "./EntityPickerList.vue";
export default {
@@ -78,9 +78,9 @@
</template>
<script>
import { accessApi, usersApi } from "@/api/index.js";
import { eventBus } from "@/store/eventBus.ts";
import { notify } from "@/notify/index.ts";
import { accessApi, usersApi } from "@/api";
import { eventBus } from "@/store/eventBus";
import { notify } from "@/notify";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import { groupNameErrorText } from "@/utils/groups.js";
@@ -123,10 +123,7 @@ export default {
default: true,
},
},
emits: {
"update:modelValue": (names) => Array.isArray(names),
enter: () => true,
},
emits: ["update:modelValue", "enter"],
data() {
return {
loading: true,
@@ -24,7 +24,7 @@
<script>
import FileList from "@/components/files/FileList.vue";
import ListingFilter from "@/components/files/ListingFilter.vue";
import { mutations } from "@/store/index.ts";
import { mutations } from "@/store";
export default {
name: "file-list",
@@ -49,10 +49,10 @@
</template>
<script>
import { accessApi } from "@/api/index.js";
import { mutations } from "@/store/index.ts";
import { notify } from "@/notify/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import { accessApi } from "@/api";
import { mutations } from "@/store";
import { notify } from "@/notify";
import { eventBus } from "@/store/eventBus";
import EntityPickerList from "./EntityPickerList.vue";
import { groupNameErrorText } from "@/utils/groups.js";
+35 -35
View File
@@ -12,23 +12,23 @@
<div class="shortcut-section">
<h3 class="section-title">{{ $t("help.functionKeys") }}</h3>
<div class="shortcut-item">
<kbd>F1</kbd>
<kbd>F1</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.f1") }}</span>
</div>
<div class="shortcut-item">
<kbd>F2</kbd>
<kbd>F2</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.f2") }}</span>
</div>
<div class="shortcut-item">
<kbd>F4</kbd>
<kbd>F4</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.f4") }}</span>
</div>
<div class="shortcut-item">
<kbd>DEL</kbd>
<kbd>DEL</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.del") }}</span>
</div>
<div class="shortcut-item">
<kbd>ESC</kbd>
<kbd>ESC</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.esc") }}</span>
</div>
</div>
@@ -37,23 +37,23 @@
<div class="shortcut-section">
<h3 class="section-title">{{ $t("help.mouseActions") }}</h3>
<div class="shortcut-item">
<kbd>CLICK</kbd>
<kbd>CLICK</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.click") }}</span>
</div>
<div class="shortcut-item">
<kbd>DOUBLE CLICK</kbd>
<kbd>DOUBLE CLICK</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.dbClick") }}</span>
</div>
<div class="shortcut-item">
<kbd>CLICK + DRAG</kbd>
<kbd>CLICK + DRAG</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.marqueeSelect") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + CLICK</kbd>
<kbd>CTRL + CLICK</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.click") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + SCROLL</kbd>
<kbd>CTRL + SCROLL</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.scroll") }}</span>
</div>
</div>
@@ -62,19 +62,19 @@
<div class="shortcut-section">
<h3 class="section-title">{{ $t("help.fileOperations") }}</h3>
<div class="shortcut-item">
<kbd>CTRL + C</kbd>
<kbd>CTRL + C</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.c") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + X</kbd>
<kbd>CTRL + X</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.x") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + V</kbd>
<kbd>CTRL + V</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.v") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + D</kbd>
<kbd>CTRL + D</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.d") }}</span>
</div>
@@ -84,39 +84,39 @@
<div class="shortcut-section">
<h3 class="section-title">{{ $t("general.navigation") }}</h3>
<div class="shortcut-item">
<kbd>BACKSPACE {{ $t("general.or") }} ALT + ↑</kbd>
<kbd>BACKSPACE {{ $t("general.or") }} ALT + ↑</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.altArrowUp") }}</span>
</div>
<div class="shortcut-item">
<kbd>ENTER {{ $t("general.or") }} ALT + ↓</kbd>
<kbd>ENTER {{ $t("general.or") }} ALT + ↓</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.altArrowDown") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + B</kbd>
<kbd>CTRL + B</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.b") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + ,</kbd>
<kbd>CTRL + ,</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.comma") }}</span>
</div>
<div class="shortcut-item">
<kbd>SPACE {{ $t("general.or") }} /</kbd>
<kbd>SPACE {{ $t("general.or") }} /</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.slash") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + E</kbd>
<kbd>CTRL + E</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.e") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + F1</kbd>
<kbd>CTRL + F1</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.f1") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + F2</kbd>
<kbd>CTRL + F2</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.f2") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + F3</kbd>
<kbd>CTRL + F3</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.f3") }}</span>
</div>
</div>
@@ -125,47 +125,47 @@
<div class="shortcut-section">
<h3 class="section-title">{{ $t("help.media.title") }}</h3>
<div class="shortcut-item">
<kbd>SPACE / K</kbd>
<kbd>SPACE / K</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.playPause") }}</span>
</div>
<div class="shortcut-item">
<kbd>1 {{ $t("general.to") }} 9</kbd>
<kbd>1 {{ $t("general.to") }} 9</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.seekPercentage") }}</span>
</div>
<div class="shortcut-item">
<kbd>← / →</kbd>
<kbd>← / →</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.seek") }}</span>
</div>
<div class="shortcut-item">
<kbd>↑ / ↓</kbd>
<kbd>↑ / ↓</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.volume") }}</span>
</div>
<div class="shortcut-item">
<kbd>Q</kbd>
<kbd>Q</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.playbackQueue") }}</span>
</div>
<div class="shortcut-item">
<kbd>E</kbd>
<kbd>E</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.switchTabs") }}</span>
</div>
<div class="shortcut-item">
<kbd>L</kbd>
<kbd>L</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.loop") }}</span>
</div>
<div class="shortcut-item">
<kbd>P</kbd>
<kbd>P</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.playbackModes") }}</span>
</div>
<div class="shortcut-item">
<kbd>F</kbd>
<kbd>F</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.fullscreen") }}</span>
</div>
<div class="shortcut-item">
<kbd>M</kbd>
<kbd>M</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.mute") }}</span>
</div>
<div class="shortcut-item">
<kbd>C</kbd>
<kbd>C</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.captions") }}</span>
</div>
</div>
@@ -181,7 +181,7 @@
</template>
<script>
import { mutations } from "@/store/index.ts"; // Import the mutations
import { mutations } from "@/store"; // Import the mutations
export default {
name: "help",
@@ -97,8 +97,8 @@
import {
materialSymbols,
getIconClass,
} from "@/utils/material-symbols.js";
import { mutations } from "@/store/index.ts";
} from "@/utils/material-symbols";
import { mutations } from "@/store";
export default {
name: "IconPicker",
+38 -38
View File
@@ -6,40 +6,40 @@
<div class="info-grid" :class="{ 'info-grid--fill': activeTab === 'access' }">
<div class="info-section" v-if="activeTab === 'basic'">
<div class="info-item">
<strong>{{ t("prompts.displayName") }}</strong>
<strong>{{ $t("prompts.displayName") }}</strong>
<span role="group" aria-label="info display name">{{ item.name }}</span>
</div>
<div class="info-item">
<strong>{{ t("general.size") }}</strong>
<strong>{{ $t("general.size") }}</strong>
<span role="group" aria-label="info size">{{ humanSize }}</span>
</div>
<div class="info-item">
<strong>{{ t("general.type") }}</strong>
<strong>{{ $t("general.type") }}</strong>
<span role="group" aria-label="info type">{{ item.type }}</span>
</div>
<div class="info-item" v-if="humanTime">
<strong>{{ t("files.lastModified") }}</strong>
<strong>{{ $t("files.lastModified") }}</strong>
<span role="group" aria-label="info last modified" :title="modTime">{{ humanTime }}</span>
</div>
<div class="info-item" v-if="humanCreatedTime">
<strong>{{ t("files.creationTime") }}</strong>
<strong>{{ $t("files.creationTime") }}</strong>
<span aria-label="info date added" :title="createdTime">{{ humanCreatedTime }}</span>
</div>
<div class="info-item" v-if="source">
<strong>{{ t("general.source") }}</strong>
<strong>{{ $t("general.source") }}</strong>
<span role="group" aria-label="info source">{{ source }}</span>
</div>
<div class="info-item" v-if="filePath">
<strong>{{ t("general.path") }}</strong>
<strong>{{ $t("general.path") }}</strong>
<span role="group" aria-label="info path" class="break-word">{{ filePath }}</span>
</div>
<div class="info-item" v-if="item.hidden !== undefined">
<strong>{{ t("prompts.hidden") }}</strong>
<span role="group" aria-label="info hidden">{{ item.hidden ? "✓" : "✗" }}</span>
<strong>{{ $t("prompts.hidden") }}</strong>
<span role="group" aria-label="info hidden">{{ item.hidden ? "✓" : "✗" }}</span><!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="info-item" v-if="item.hasPreview !== undefined">
<strong>{{ t("prompts.hasPreview") }}</strong>
<span role="group" aria-label="info has preview">{{ item.hasPreview ? "✓" : "✗" }}</span>
<strong>{{ $t("prompts.hasPreview") }}</strong>
<span role="group" aria-label="info has preview">{{ item.hasPreview ? "✓" : "✗" }}</span><!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="settings-items" v-if="source && !isShare">
<ActivityViewerButton :href="activityViewerHref" />
@@ -53,27 +53,27 @@
</div>
<!-- Lyrics -->
<div class="info-item" v-if="hasLyrics">
<strong>{{ t("player.lyrics") }}</strong>
<strong>{{ $t("player.lyrics") }}</strong>
<span>
<a class="info-toggle" role="button" tabindex="0" @click="toggleLyrics" @keydown.enter="toggleLyrics">
{{ showLyrics ? t("general.hide") : t("general.show") }}
{{ showLyrics ? $t("general.hide") : $t("general.show") }}
</a>
<span v-if="showLyrics" class="info-multiline">{{ lyricsText }}</span>
</span>
</div>
<!-- Subtitles -->
<div class="info-item" v-for="(track, idx) in subtitleTracks" :key="track.name">
<strong>{{ t("prompts.subtitle", { n: idx + 1 }) }}</strong>
<strong>{{ $t("prompts.subtitle", { n: idx + 1 }) }}</strong>
<span>
{{ subtitleLabel(track) }}
<a class="info-toggle" role="button" tabindex="0" @click="toggleSubtitle(track)" @keydown.enter="toggleSubtitle(track)">
{{ subtitleContent[track.name] !== undefined ? t("general.hide") : t("general.show") }}
{{ subtitleContent[track.name] !== undefined ? $t("general.hide") : $t("general.show") }}
</a>
<span v-if="subtitleContent[track.name] !== undefined" class="info-multiline">{{ subtitleContent[track.name] }}</span>
</span>
</div>
<div class="info-item" v-if="!showMediaContent">
<span>{{ t("general.none") }}</span>
<span>{{ $t("general.none") }}</span>
</div>
</div>
<div v-else-if="activeTab === 'quota'" class="info-section">
@@ -81,13 +81,13 @@
<SettingsButton
class="info-manage-link"
:name="t('quotas.title')"
:description="t('quotas.openDescription')"
@activate="openQuotaPrompt"
:description="$t('quotas.openDescription')"
@click="openQuotaPrompt"
/>
</div>
<div class="info-item">
<strong>{{ t("general.enabled") }}</strong>
<span>{{ quotaEnabled ? t("general.yes") : t("general.no") }}</span>
<strong>{{ $t("general.enabled") }}</strong>
<span>{{ quotaEnabled ? $t("general.yes") : $t("general.no") }}</span>
</div>
<div v-if="quotaEnabled" class="info-quota-usage">
<QuotaFolderBar
@@ -109,33 +109,33 @@
<div class="info-section" v-else-if="activeTab === 'checksums'">
<div class="hash-generator">
<div class="hash-select">
<label for="hash-algo">{{ t("prompts.hashAlgorithm") }}</label>
<label for="hash-algo">{{ $t("prompts.hashAlgorithm") }}</label>
<div class="form-flex-group">
<ExpandDropdown
input-id="hash-algo"
v-model="selectedHashAlgo"
class="form-form flat-right"
:options="hashAlgoOptions"
:aria-label="t('prompts.hashAlgorithm')"
:aria-label="$t('prompts.hashAlgorithm')"
/>
<button type="button" class="button form-button flat-left" @click="generateHash"
:title="t('prompts.generate')" :aria-label="t('prompts.generate')">
{{ t("prompts.generate") }}
:title="$t('prompts.generate')" :aria-label="$t('prompts.generate')">
{{ $t("prompts.generate") }}
</button>
</div>
</div>
<div class="hash-result">
<label for="hash-result">{{ t("prompts.hashValue") }}</label>
<label for="hash-result">{{ $t("prompts.hashValue") }}</label>
<div class="form-flex-group">
<input id="hash-result" class="input form-form flat-right" type="text" :value="hashResult" readonly
:placeholder="t('prompts.selectHashAlgorithm')" />
:placeholder="$t('prompts.selectHashAlgorithm')" />
<button
type="button"
class="button form-button flat-left"
@click="copyHash"
:disabled="!hashResult"
:title="t('buttons.copyToClipboard')"
:aria-label="t('buttons.copyToClipboard')"
:title="$t('buttons.copyToClipboard')"
:aria-label="$t('buttons.copyToClipboard')"
>
<i class="material-symbols-outlined" style="font-size: 16px;">content_copy</i>
</button>
@@ -152,16 +152,16 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "@/i18n/index.ts";
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
import { formatTimestamp, fromNow } from "@/utils/moment.ts";
import { copyToClipboard } from "@/utils/clipboard.js";
import { formatDuration } from "@/utils/files.js";
import { resourcesApi, quotasApi, mediaApi } from "@/api/index.js";
import { getters, mutations, state } from "@/store/index.ts";
import { useI18n } from "vue-i18n";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { formatTimestamp, fromNow } from "@/utils/moment";
import { copyToClipboard } from "@/utils/clipboard";
import { formatDuration } from "@/utils/files";
import { resourcesApi, quotasApi, mediaApi } from "@/api";
import { getters, mutations, state } from "@/store";
import type { FileListItem, SubtitleTrack } from "@/store/types.ts";
import { notify } from "@/notify/index.ts";
import { activityViewerPresets } from "@/utils/activityViewerLink.js";
import { notify } from "@/notify";
import { activityViewerPresets } from "@/utils/activityViewerLink";
import SlidingTabs from "@/components/settings/SlidingTabs.vue";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import ActivityViewerButton from "@/components/settings/ActivityViewerButton.vue";
@@ -355,7 +355,7 @@ function subtitleLabel(track: SubtitleTrack) {
// Raw subtitle content are fetched on first show
async function toggleSubtitle(track: SubtitleTrack) {
if (track.name in subtitleContent.value) {
Reflect.deleteProperty(subtitleContent.value, track.name);
delete subtitleContent.value[track.name];
return;
}
try {
@@ -56,10 +56,10 @@
</template>
<script>
import { notify } from "@/notify/index.ts";
import { mutations } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import { getMediaSettings } from "@/api/settings.js";
import { notify } from "@/notify";
import { mutations } from "@/store";
import { eventBus } from "@/store/eventBus";
import { getMediaSettings } from "@/api/settings";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import {
@@ -67,7 +67,7 @@ import {
emptyMediaSettings,
mergeMediaSettings,
saveMediaSettingsPatch,
} from "@/utils/mediaSettings.js";
} from "@/utils/mediaSettings";
export default {
name: "MediaConversionFormats",
@@ -97,7 +97,7 @@
:options="backendSelectOptions"
:aria-label="$t('media.encoderBackend')"
:disabled="isLocked(`transcode.profiles.${profile.id}.encoderBackend`)"
@update:model-value="onProfileBackendChange(profile.id, $event)" />
@update:modelValue="onProfileBackendChange(profile.id, $event)" />
</div>
</div>
</template>
@@ -128,10 +128,10 @@
</template>
<script>
import { notify } from "@/notify/index.ts";
import { mutations } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import { getMediaSettings } from "@/api/settings.js";
import { notify } from "@/notify";
import { mutations } from "@/store";
import { eventBus } from "@/store/eventBus";
import { getMediaSettings } from "@/api/settings";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
@@ -142,7 +142,7 @@ import {
emptyMediaSettings,
mergeMediaSettings,
saveMediaSettingsPatch,
} from "@/utils/mediaSettings.js";
} from "@/utils/mediaSettings";
export default {
name: "MediaTranscodeSettings",
+9 -9
View File
@@ -79,24 +79,24 @@
</template>
<script>
import { mutations, state, getters } from "@/store/index.ts";
import { mutations, state, getters } from "@/store";
import FileList from "../files/FileList.vue";
import { resourcesApi } from "@/api/index.js";
import buttons from "@/utils/buttons.js";
import { checkConflict } from "@/utils/upload.ts";
import { url } from "@/utils/index.ts";
import { notify } from "@/notify/index.ts";
import { resourcesApi } from "@/api";
import buttons from "@/utils/buttons";
import { checkConflict } from "@/utils/upload";
import { url } from "@/utils";
import { notify } from "@/notify";
import {
notifyMoveCopyComplete,
notifyOperationError,
notifyMoveCopyFailure,
extractMoveCopyErrorMessage,
} from "@/utils/appNotifications.js";
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
} from "@/utils/appNotifications";
import { goToItemNotificationButton } from "@/utils/notificationActions";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import PathPickerButton from "@/components/files/PathPickerButton.vue";
import ListingFilter from "@/components/files/ListingFilter.vue";
import { eventBus } from "@/store/eventBus.ts";
import { eventBus } from '@/store/eventBus';
import { removeFromFolderLists } from "@/utils/cache/listingCache.ts";
export default {
+5 -5
View File
@@ -35,11 +35,11 @@
</div>
</template>
<script>
import { resourcesApi } from "@/api/index.js";
import { getters, mutations, state } from "@/store/index.ts"; // Import your custom store
import { url } from "@/utils/index.ts";
import { notify } from "@/notify/index.ts";
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
import { resourcesApi } from "@/api";
import { getters, mutations, state } from "@/store"; // Import your custom store
import { url } from "@/utils";
import { notify } from "@/notify";
import { goToItemNotificationButton } from "@/utils/notificationActions";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
export default {
name: "new-dir",
+8 -8
View File
@@ -12,7 +12,7 @@
<input ref="filenameInput" class="input" aria-label="FileName Field" v-focus type="text" @keyup.enter="submit"
v-model.trim="fileName" @input="updateFullName" />
<template v-if="fileExtension">
<span class="extension-separator">.</span>
<span class="extension-separator">.</span> <!--eslint-disable-line @intlify/vue-i18n/no-raw-text-->
<input
class="input extension-input"
type="text"
@@ -51,13 +51,13 @@
</div>
</template>
<script>
import { state } from "@/store/index.ts";
import { resourcesApi } from "@/api/index.js";
import { getters, mutations } from "@/store/index.ts"; // Import your custom store
import { notify } from "@/notify/index.ts";
import { url } from "@/utils/index.ts";
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
import { getTypeInfoFromExt } from "@/utils/mimetype.js";
import { state } from "@/store";
import { resourcesApi } from "@/api";
import { getters, mutations } from "@/store"; // Import your custom store
import { notify } from "@/notify";
import { url } from "@/utils";
import { goToItemNotificationButton } from "@/utils/notificationActions";
import { getTypeInfoFromExt } from "@/utils/mimetype";
import { getFileExtension, removePrefix } from '@/utils/files.js';
import LoadingSpinner from "@/components/LoadingSpinner.vue";
export default {
@@ -89,8 +89,8 @@
</template>
<script>
import { getters, mutations } from "@/store/index.ts";
import { getTypeInfoFromExt } from "@/utils/mimetype.js";
import { getters, mutations } from "@/store";
import { getTypeInfoFromExt } from "@/utils/mimetype";
import { createDragReorder } from "@/utils/dragAndDropReorder.js";
export default {
+31 -30
View File
@@ -2,38 +2,38 @@
<div class="card-content no-buttons">
<div class="debug-tooltip">
<div class="debug-header">
<h3 class="debug-title">🔧 {{ $t("onlyoffice.debugTitle") }}</h3>
<h3 class="debug-title">🔧 {{ $t("onlyoffice.debugTitle") }}</h3> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="debug-section debug-section-basic">
<div class="debug-section-header">
<strong class="debug-section-title">⚙️ {{ $t("onlyoffice.basicConfiguration") }}</strong>
<strong class="debug-section-title">⚙️ {{ $t("onlyoffice.basicConfiguration") }}</strong> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
{{ $t("onlyoffice.onlyOffice") }} {{ $t("general.path", { suffix: ":" }) }} {{ onlyOfficeUrl }}<br/>
{{ $t("onlyoffice.internalUrl") }}: {{ internalUrlInfo.message }}<br/>
{{ $t("general.path", { suffix: ":" }) }} {{ baseURL }}<br/>
{{ $t("general.source", { suffix: ":" }) }} {{ source }}<br/>
{{ $t("general.path", { suffix: ":" }) }} {{ path }}<br/>
{{ $t("general.name", { suffix: ":" }) }} {{ filename }}<br/>
{{ isShare ? `${shareHashLabel()}: ${shareHash}` : $t("onlyoffice.userRequest") }}<br/>
{{ $t("onlyoffice.onlyOffice") }} {{ $t("general.path", { suffix: ":" }) }} {{ onlyOfficeUrl }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.internalUrl") }}: {{ internalUrlInfo.message }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("general.path", { suffix: ":" }) }} {{ baseURL }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("general.source", { suffix: ":" }) }} {{ source }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("general.path", { suffix: ":" }) }} {{ path }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("general.name", { suffix: ":" }) }} {{ filename }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ isShare ? `${shareHashLabel()}: ${shareHash}` : $t("onlyoffice.userRequest") }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div v-if="clientConfig?.document" class="debug-section debug-section-config">
<div class="debug-section-header">
<strong class="debug-section-title">🔧 {{ $t("onlyoffice.configurationDetails") }}</strong>
<strong class="debug-section-title">🔧 {{ $t("onlyoffice.configurationDetails") }}</strong> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
{{ $t("onlyoffice.documentKey") }}: {{ clientConfig.document.key }}<br/>
{{ $t("onlyoffice.fileType") }}: {{ clientConfig.document.fileType }}<br/>
{{ $t("onlyoffice.editMode") }}: {{ clientConfig.editorConfig ? clientConfig.editorConfig.mode : 'N/A' }}<br/>
{{ $t("onlyoffice.downloadURL") }}: {{ clientConfig.document.url ? `${clientConfig.document.url.substring(0, 80)}...` : 'N/A' }}<br/>
{{ $t("onlyoffice.callbackURL") }}: {{ clientConfig.editorConfig?.callbackUrl ? `${clientConfig.editorConfig.callbackUrl.substring(0, 80)}...` : 'N/A' }}<br/>
{{ $t("onlyoffice.documentKey") }}: {{ clientConfig.document.key }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.fileType") }}: {{ clientConfig.document.fileType }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.editMode") }}: {{ clientConfig.editorConfig ? clientConfig.editorConfig.mode : 'N/A' }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.downloadURL") }}: {{ clientConfig.document.url ? `${clientConfig.document.url.substring(0, 80)}...` : 'N/A' }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.callbackURL") }}: {{ clientConfig.editorConfig?.callbackUrl ? `${clientConfig.editorConfig.callbackUrl.substring(0, 80)}...` : 'N/A' }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<br/>
<strong>{{ $t("onlyoffice.networkFlow") }}:</strong><br/>
{{ $t("onlyoffice.browser") }} ({{ windowOrigin }}) ↔ {{ $t("onlyoffice.onlyOffice") }}: {{ onlyOfficeUrl }}<br/>
{{ $t("onlyoffice.onlyOffice") }} → {{ $t("onlyoffice.fileBrowser") }}: {{ downloadDomain }}<br/>
<strong>{{ $t("onlyoffice.networkFlow") }}:</strong><br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.browser") }} ({{ windowOrigin }}) ↔ {{ $t("onlyoffice.onlyOffice") }}: {{ onlyOfficeUrl }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.onlyOffice") }} → {{ $t("onlyoffice.fileBrowser") }}: {{ downloadDomain }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="debug-section">
<strong>{{ $t("onlyoffice.processSteps") }}:</strong><br/>
<strong>{{ $t("onlyoffice.processSteps") }}:</strong><br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<div v-for="(step, i) in debugInfo" :key="i">
{{ step.text }}
<template v-for="(line, j) in step.lines" :key="j">
@@ -42,20 +42,20 @@
</template>
<template v-if="step.link">
<br/>
<a :href="wikiLink" target="_blank" rel="noopener noreferrer">📖 {{ $t("onlyoffice.troubleshootingGuide") }}</a>
<a :href="wikiLink" target="_blank" rel="noopener noreferrer">📖 {{ $t("onlyoffice.troubleshootingGuide") }}</a> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</template>
</div>
</div>
<div v-if="onlyOfficeLogs.length > 0" class="debug-section debug-section-logs">
<div class="debug-section-header-logs">
<strong class="debug-section-title-logs">📋 {{ $t("onlyoffice.backendLogs") }}</strong>
<strong class="debug-section-title-logs">📋 {{ $t("onlyoffice.backendLogs") }}</strong> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span class="debug-section-counter">{{ onlyOfficeLogs.length }} {{ $t("onlyoffice.entries") }}</span>
</div>
<div v-for="log in displayedLogs" :key="log.id" class="debug-log-entry">
<span class="debug-log-level" :style="{ color: getLogLevelColor(log.level) }">[{{ log.level }}]</span>
<span class="debug-log-level" :style="{ color: getLogLevelColor(log.level) }">[{{ log.level }}]</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span class="debug-log-timestamp">{{ formatTimestamp(log.timestamp) }}</span>
<span class="debug-log-component">[{{ log.component }}]</span>
<span class="debug-log-component">[{{ log.component }}]</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span class="debug-log-message">{{ log.message }}</span>
</div>
</div>
@@ -64,14 +64,15 @@
</div>
<div v-if="overallStatus === 'error'" class="debug-status-error">
<strong>❌ {{ $t("onlyoffice.errorDetected") }}</strong><br/>
<strong>❌ {{ $t("onlyoffice.errorDetected") }}</strong><br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.integrationFailed") }}<br/>
<a :href="wikiLink" target="_blank" rel="noopener noreferrer">
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
📖 {{ $t("onlyoffice.troubleshootingGuide") }}
</a>
</div>
<div v-else-if="overallStatus === 'success'" class="debug-status-success">
<strong>🎉 {{ $t("onlyoffice.documentInitialized") }}</strong><br/>
<strong>🎉 {{ $t("onlyoffice.documentInitialized") }}</strong><br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.integrationSuccess") }}
</div>
</div>
@@ -79,10 +80,10 @@
</template>
<script>
import { state, getters } from "@/store/index.ts";
import { globalVars } from "@/utils/constants.ts";
import { officeApi } from "@/api/index.js";
import { events } from "@/notify/index.ts";
import { state, getters } from "@/store";
import { globalVars } from "@/utils/constants";
import { officeApi } from "@/api";
import { events } from "@/notify";
const wikiLink = "https://filebrowserquantum.com/en/docs/integrations/office/troubleshooting/";
@@ -110,7 +111,7 @@ export default {
return {
debugInfo: [{ text: `⏳ ${this.$t("onlyoffice.startingInit")}` }],
wikiLink,
clientConfig: { document: null, editorConfig: null },
clientConfig: {},
hasErrors: false,
onlyOfficeLogs: [],
documentId: null,
+1 -1
View File
@@ -33,7 +33,7 @@
</template>
<script>
import { mutations } from "@/store/index.ts";
import { mutations } from "@/store";
export default {
name: "password",
@@ -1,6 +1,6 @@
<template>
<div class="card-content">
<file-list @update:selected="updateSelection" :browseSource="listBrowseSource"
<file-list ref="fileList" @update:selected="updateSelection" :browseSource="listBrowseSource"
:browse-path="initialBrowsePath" :hide-destination-source="hideDestinationSource" :showFiles="showFiles"
:showFolders="showFolders" :require-file-selection="requireFileSelection"
:allowed-file-types="allowedFileTypes" :title="listTitle" v-model:filter-query="filterQuery">
@@ -22,10 +22,10 @@
</template>
<script>
import { mutations, getters } from "@/store/index.ts";
import { mutations, getters } from "@/store";
import FileList from "../files/FileList.vue";
import ListingFilter from "@/components/files/ListingFilter.vue";
import { eventBus } from "@/store/eventBus.ts";
import { eventBus } from "@/store/eventBus";
export default {
name: "path-picker",
@@ -16,8 +16,8 @@
type="button"
class="clear-queue-btn"
@click="clearQueue"
:title="t('player.clearQueue')"
:aria-label="t('player.clearQueue')"
:title="$t('player.clearQueue')"
:aria-label="$t('player.clearQueue')"
>
<i class="material-symbols">delete</i>
</button>
@@ -63,6 +63,7 @@
<span v-if="item.fileType" class="file-type-badge">{{ item.fileType }}</span>
<span class="queue-item-indicator">
<span v-if="item.index === currentQueueIndex" class="current-track">
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<i class="material-symbols">{{ isPlaying ? 'pause' : 'play_arrow' }}</i>
</span>
<span v-else class="track-number">{{ item.index + 1 }}</span>
@@ -75,7 +76,7 @@
<!-- Empty state -->
<div v-else class="empty">
<i class="material-symbols">queue_music</i>
<p>{{ t('player.emptyQueue') }}</p>
<p>{{ $t('player.emptyQueue') }}</p>
</div>
</div>
@@ -95,11 +96,11 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, useTemplateRef, watch } from "vue";
import { useI18n } from "@/i18n/index.ts";
import { state, mutations, getters } from "@/store/index.ts";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { state, mutations, getters } from "@/store";
import type { MediaMetadata } from "@/store/types.ts";
import { url } from "@/utils/index.ts";
import { url } from "@/utils";
import {
cyclePlaybackModes,
cycleLoopState,
@@ -113,8 +114,8 @@ import {
} from '@/utils/playbackQueue.js';
import Icon from "@/components/files/Icon.vue";
import SlidingTabs from "@/components/settings/SlidingTabs.vue";
import { resourcesApi } from "@/api/index.js";
import { globalVars } from "@/utils/constants.ts";
import { resourcesApi } from "@/api";
import { globalVars } from "@/utils/constants";
import { formatDuration, getTypeFromMime, removeExtension } from "@/utils/files.js";
import { findScrollParents, useListingVirtualization } from "@/composables/useListingVirtualization.ts";
import type { VirtualItem } from "@/composables/useListingVirtualization.ts";
@@ -144,7 +145,7 @@ const props = withDefaults(
const { t } = useI18n();
const QueueList = useTemplateRef<HTMLElement>("QueueList");
const QueueList = ref<HTMLElement | null>(null);
// Only renders the rows around the screen when the queue is long
const virtual = useListingVirtualization<VirtualItem>({
container: QueueList,
+1 -1
View File
@@ -139,7 +139,7 @@ import NewFileTemplate from "./NewFileTemplate.vue";
import DefaultViewPrefs from "./DefaultViewPrefs.vue";
import MediaConversionFormats from "./MediaConversionFormats.vue";
import MediaTranscodeSettings from "./MediaTranscodeSettings.vue";
import { state, getters, mutations } from "@/store/index.ts";
import { state, getters, mutations } from "@/store";
import { getObjectProperty, omitObjectProperty, setObjectProperty } from "@/utils/object.js";
export default {
+9 -7
View File
@@ -34,9 +34,11 @@
<p>{{ $t("general.limit") }}</p>
<div class="quota-custom-row">
<QuotaCustomLimitInput
v-model:amount="customAmount"
v-model:unit="customUnit"
:amount="customAmount"
:unit="customUnit"
:aria-label="$t('general.limit')"
@update:amount="customAmount = $event"
@update:unit="customUnit = $event"
/>
</div>
</div>
@@ -51,19 +53,19 @@
</template>
<script>
import { mutations, state } from "@/store/index.ts";
import { quotasApi } from "@/api/index.js";
import { mutations, state } from "@/store";
import { quotasApi } from "@/api";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import QuotaFolderBar from "@/components/prompts/QuotaFolderBar.vue";
import ActivityViewerButton from "@/components/settings/ActivityViewerButton.vue";
import QuotaCustomLimitInput from "@/components/settings/QuotaCustomLimitInput.vue";
import { activityViewerPresets } from "@/utils/activityViewerLink.js";
import { notify } from "@/notify/index.ts";
import { activityViewerPresets } from "@/utils/activityViewerLink";
import { notify } from "@/notify";
import {
bytesFromCustomAmount,
customAmountFromBytes,
} from "@/utils/quotaUnits.js";
} from "@/utils/quotaUnits";
export default {
name: "Quota",
+8 -7
View File
@@ -11,7 +11,7 @@
<div v-if="item.type !== 'directory'" class="filename-inputs">
<i class="file-type-icon" :class="typeInfo.classes" aria-hidden="true">{{ typeInfo.materialSymbol }}</i>
<input ref="filenameInput" aria-label="New Name" class="input" :class="{ 'form-invalid': !validation.valid }" v-focus type="text" @keydown="onKeydown" @keyup="onKeyup" v-model.trim="fileName" @input="updateFullName" />
<span class="extension-separator">.</span>
<span class="extension-separator">.</span> <!--eslint-disable-line @intlify/vue-i18n/no-raw-text-->
<input
class="input extension-input"
type="text"
@@ -54,13 +54,13 @@
</div>
</template>
<script>
import { resourcesApi } from "@/api/index.js";
import { mutations, state, getters } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import { notify } from "@/notify/index.ts";
import { resourcesApi } from "@/api";
import { mutations, state, getters } from "@/store";
import { eventBus } from '@/store/eventBus';
import { notify } from "@/notify";
import { getFileExtension, removePrefix } from '@/utils/files.js';
import { getTypeInfo, getTypeInfoFromExt } from "@/utils/mimetype.js";
import { url } from "@/utils/index.ts";
import { getTypeInfo, getTypeInfoFromExt } from "@/utils/mimetype";
import { url } from "@/utils";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
export default {
name: "rename",
@@ -74,6 +74,7 @@ export default {
},
item: {
type: Object,
required: true,
default: () => ({ source: "", path: "", name: "" })
},
parentItems: { // Parent items for comparison of filenames in a preview
+1 -1
View File
@@ -24,7 +24,7 @@
</div>
</template>
<script>
import { getters, mutations } from "@/store/index.ts"; // Import your custom store
import { getters, mutations } from "@/store"; // Import your custom store
export default {
name: "replace",
@@ -54,7 +54,7 @@
</template>
<script>
import { getters, state } from "@/store/index.ts";
import { getters, state } from "@/store";
export default {
name: "replace-rename",
@@ -50,10 +50,10 @@
</template>
<script>
import { mutations } from "@/store/index.ts";
import { notify } from "@/notify/index.ts";
import { authApi } from "@/api/index.js";
import { initAuth } from "@/utils/auth.js";
import { mutations } from "@/store";
import { notify } from "@/notify";
import { authApi } from "@/api";
import { initAuth } from "@/utils/auth";
import { evaluatePasswordPolicy } from "@/utils/passwordPolicy.js";
import PasswordRequirementsHint from "@/components/PasswordRequirementsHint.vue";
@@ -32,7 +32,7 @@
</template>
<script>
import { getters, mutations } from "@/store/index.ts";
import { getters, mutations } from "@/store";
export default {
name: "saveBeforeExit",
@@ -49,18 +49,18 @@ export default {
},
created() {
// Listen for ESC key to treat it as "Keep Editing"
this.escKeyHandler = (event) => {
if (event.keyCode === 27 && getters.currentPromptName() === "SaveBeforeExit") {
event.stopImmediatePropagation();
this.keepEditing();
}
};
window.addEventListener("keydown", this.escKeyHandler, true);
},
beforeUnmount() {
window.removeEventListener("keydown", this.escKeyHandler, true);
},
methods: {
escKeyHandler(event) {
if (event.keyCode === 27 && getters.currentPromptName() === "SaveBeforeExit") {
event.stopImmediatePropagation();
this.keepEditing();
}
},
keepEditing() {
if (this.currentPrompt?.cancel) {
this.currentPrompt.cancel();
+11 -10
View File
@@ -183,24 +183,24 @@
</div>
</template>
<script>
import { notify } from "@/notify/index.ts";
import { state, getters, mutations } from "@/store/index.ts";
import { shareApi } from "@/api/index.js";
import { copyToClipboard } from "@/utils/clipboard.js";
import { fromNowSeconds } from "@/utils/moment.ts";
import { buildItemUrl } from "@/utils/url.js";
import { notify } from "@/notify";
import { state, getters, mutations } from "@/store";
import { shareApi } from "@/api";
import { copyToClipboard } from "@/utils/clipboard";
import { fromNowSeconds } from "@/utils/moment";
import { buildItemUrl } from "@/utils/url";
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
import SettingsTable from "@/components/settings/Table.vue";
import ActivityViewerButton from "@/components/settings/ActivityViewerButton.vue";
import ShareOptionsForm from "@/components/share/ShareOptionsForm.vue";
import { activityViewerPresets } from "@/utils/activityViewerLink.js";
import { activityViewerPresets } from "@/utils/activityViewerLink";
import FileList from "../files/FileList.vue";
import { eventBus } from "@/store/eventBus.ts";
import { eventBus } from "@/store/eventBus";
import {
bytesFromCustomAmount,
customAmountFromBytes,
} from "@/utils/quotaUnits.js";
import { applyShareDefaultsToForm } from "@/utils/shareDefaultsForm.js";
} from "@/utils/quotaUnits";
import { applyShareDefaultsToForm } from "@/utils/shareDefaultsForm";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
//import ViewMode from "@/components/settings/ViewMode.vue";
@@ -603,6 +603,7 @@ export default {
},
beforeUnmount() {
// Clean up event listeners
eventBus.off('apiKeysChanged', this.reloadApiKeys);
eventBus.off('shareSidebarLinksUpdated', this.handleSidebarLinksUpdate);
eventBus.off('pathSelected', this.onBannerFaviconPathSelected);
eventBus.off('pathPickerCancelled', this.onBannerFaviconPathPickerCancelled);
@@ -8,7 +8,7 @@
enforceable
:enforced="enforced"
:read-only-source="false"
@enforced-change="({ field, value }) => onEnforcedChange(field, value)"
@enforced-change="onEnforcedChange"
@customize-sidebar-links="openSidebarLinksCustomization"
/>
</div>
@@ -38,17 +38,17 @@
</template>
<script>
import { notify } from "@/notify/index.ts";
import { mutations } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import { getShareDefaults, patchShareDefaults } from "@/api/settings.js";
import { notify } from "@/notify";
import { mutations } from "@/store";
import { eventBus } from "@/store/eventBus";
import { getShareDefaults, patchShareDefaults } from "@/api/settings";
import ShareOptionsForm from "@/components/share/ShareOptionsForm.vue";
import {
emptyShareDefaultsEnforced,
emptyShareDefaultsValues,
shareDefaultsFromApi,
shareDefaultsToApiPayload,
} from "@/utils/shareDefaultsForm.js";
} from "@/utils/shareDefaultsForm";
export default {
name: "share-defaults",
@@ -34,8 +34,8 @@
</template>
<script>
import { state, getters } from "@/store/index.ts";
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
import { state, getters } from "@/store";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import QrcodeVue from "qrcode.vue";
export default {
@@ -52,11 +52,11 @@
</template>
<script>
import { shareApi } from "@/api/index.js";
import { shareApi } from "@/api";
import SettingsTable from "@/components/settings/Table.vue";
import Errors from "@/views/Errors.vue";
import { mutations } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import { mutations } from "@/store";
import { eventBus } from "@/store/eventBus";
export default {
name: "share-picker",
@@ -37,9 +37,9 @@
</template>
<script>
import { notify } from "@/notify/index.ts";
import { mutations } from "@/store/index.ts";
import { getSidebarLinkDefaults, patchSidebarLinkDefaults } from "@/api/settings.js";
import { notify } from "@/notify";
import { mutations } from "@/store";
import { getSidebarLinkDefaults, patchSidebarLinkDefaults } from "@/api/settings";
import SidebarLinksEditor from "@/components/sidebar/SidebarLinksEditor.vue";
export default {
@@ -1,5 +1,6 @@
<template>
<SidebarLinksEditor
ref="editor"
embedded
:mode="context"
:share-data="shareData"
@@ -10,10 +11,10 @@
</template>
<script>
import { notify } from "@/notify/index.ts";
import { mutations } from "@/store/index.ts";
import { shareApi } from "@/api/index.js";
import { eventBus } from "@/store/eventBus.ts";
import { notify } from "@/notify";
import { mutations } from "@/store";
import { shareApi } from "@/api";
import { eventBus } from "@/store/eventBus";
import SidebarLinksEditor from "@/components/sidebar/SidebarLinksEditor.vue";
export default {
@@ -1,30 +1,31 @@
<template>
<div class="card-content no-buttons">
<div v-if="!isMobile" class="shortcut-section">
<h3 class="section-title">⌨️ {{ $t("threejs.keyboard") }}</h3>
<h3 class="section-title">⌨️ {{ $t("threejs.keyboard") }}</h3> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<div class="shortcut-item">
<kbd>{{ $t("general.space") }}</kbd>
<span>{{ spaceText }}</span>
</div>
<div class="shortcut-item">
<kbd>R</kbd>
<kbd>R</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("general.reset") }}</span>
</div>
<div class="shortcut-item">
<kbd>Q / E</kbd>
<kbd>Q / E</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("threejs.rotateY") }}</span>
</div>
<div class="shortcut-item">
<kbd>W / S</kbd>
<kbd>W / S</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("threejs.rotateX") }}</span>
</div>
<div class="shortcut-item">
<kbd>+ / -</kbd>
<kbd>+ / -</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("general.zoom") }}</span>
</div>
</div>
<div class="shortcut-section">
<h3 class="section-title">🎨 {{ $t("general.background") }}</h3>
<h3 class="section-title">🎨 {{ $t("general.background") }}</h3> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<div class="color-control">
<input
type="color"
@@ -60,9 +61,6 @@ export default {
default: false,
},
},
emits: {
"update:backgroundColor": (color) => typeof color === "string",
},
computed: {
spaceText() {
return this.hasAnimations
@@ -38,9 +38,9 @@
</template>
<script>
import { notify } from "@/notify/index.ts";
import { mutations } from "@/store/index.ts";
import { getToolAccessDefaults, patchToolAccessDefaults } from "@/api/settings.js";
import { notify } from "@/notify";
import { mutations } from "@/store";
import { getToolAccessDefaults, patchToolAccessDefaults } from "@/api/settings";
import ToolsAccessEditor from "@/components/tools/ToolsAccessEditor.vue";
export default {
+5 -5
View File
@@ -35,11 +35,11 @@
</template>
<script>
import { mutations } from "@/store/index.ts";
import { notify } from "@/notify/index.ts";
import { authApi } from "@/api/index.js";
import { initAuth } from "@/utils/auth.js";
import { globalVars } from "@/utils/constants.ts";
import { mutations } from "@/store";
import { notify } from "@/notify";
import { authApi } from "@/api";
import { initAuth } from "@/utils/auth";
import { globalVars } from "@/utils/constants";
import QrcodeVue from "qrcode.vue";
export default {
@@ -146,11 +146,11 @@
</template>
<script>
import { state, mutations, getters } from "@/store/index.ts";
import { url } from "@/utils/index.ts";
import { notify } from "@/notify/index.ts";
import { resourcesApi } from "@/api/index.js";
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
import { state, mutations, getters } from "@/store";
import { url } from "@/utils";
import { notify } from "@/notify";
import { resourcesApi } from "@/api";
import { goToItemNotificationButton } from "@/utils/notificationActions";
import FileList from "@/components/files/FileList.vue";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
+43 -43
View File
@@ -2,12 +2,12 @@
<div class="card-content">
<p style="text-align: center">{{ uploadSettingsDescription }}</p>
<div>
<SettingsItem :title="t('fileLoading.uploadSettings')" :collapsable="true" :start-collapsed="true">
<SettingsItem :title="$t('fileLoading.uploadSettings')" :collapsable="true" :start-collapsed="true">
<div class="settings-items upload-settings">
<div class="settings-number-input item">
<div class="no-padding">
<label for="maxConcurrentUpload">{{ t("fileLoading.maxConcurrentUpload") }}</label>
<HelpTooltipIcon :text="t('fileLoading.maxConcurrentUploadHelp')" />
<label for="maxConcurrentUpload">{{ $t("fileLoading.maxConcurrentUpload") }}</label>
<HelpTooltipIcon :text="$t('fileLoading.maxConcurrentUploadHelp')" />
</div>
<div>
<input v-model.number="maxConcurrentUpload" type="range" min="1" max="10" @change="updateUploadSettings" />
@@ -16,16 +16,16 @@
</div>
<div class="settings-number-input item">
<div class="no-padding">
<label for="uploadChunkSizeMb">{{ t("fileLoading.uploadChunkSizeMb") }}</label>
<HelpTooltipIcon :text="t('fileLoading.uploadChunkSizeMbHelp')" />
<label for="uploadChunkSizeMb">{{ $t("fileLoading.uploadChunkSizeMb") }}</label>
<HelpTooltipIcon :text="$t('fileLoading.uploadChunkSizeMbHelp')" />
</div>
<div class="no-padding">
<input class="sizeInput input" v-model.number="uploadChunkSizeMb" type="number" min="0" @change="updateUploadSettings" />
</div>
</div>
<ToggleSwitch class="item" v-model="clearAll" @change="updateUploadSettings"
:name="t('fileLoading.clearAll')"
:description="t('fileLoading.clearAllDescription')" />
:name="$t('fileLoading.clearAll')"
:description="$t('fileLoading.clearAllDescription')" />
</div>
</SettingsItem>
<div class="upload-prompt" :class="{ dropping: isDragging }"
@@ -33,7 +33,7 @@
@dragover.prevent="onDragOver" @dragleave.prevent="onDragLeave" @drop.prevent="onDrop">
<div class="upload-prompt-container">
<i v-if="files.length === 0" class="material-symbols">cloud_upload</i>
<p v-if="files.length === 0">{{ t("prompts.dragAndDrop") }}</p>
<p v-if="files.length === 0">{{ $t("prompts.dragAndDrop") }}</p>
<ButtonGroup
class="upload-action-buttons"
:buttons="uploadActionButtons"
@@ -47,7 +47,7 @@
<div v-if="showConflictPrompt" class="conflict-overlay">
<div class="card">
<div class="card-content">
<p>{{ t("prompts.conflictsDetected") }}</p>
<p>{{ $t("prompts.conflictsDetected") }}</p>
</div>
<div class="card-actions">
<button
@@ -55,7 +55,7 @@
@click="resolveConflict(true)"
class="button button--flat button--red"
>
{{ t("general.replace") }}
{{ $t("general.replace") }}
</button>
</div>
</div>
@@ -67,11 +67,11 @@
<div class="file-info">
<p class="file-name">{{ file.name }}</p>
<ProgressBar v-if="file.type !== 'directory'" :val="file.status === 'completed'
? t('prompts.completed')
? $t('prompts.completed')
: file.status === 'error'
? t('prompts.error')
? $t('prompts.error')
: file.status === 'conflict'
? t('prompts.conflictsDetected')
? $t('prompts.conflictsDetected')
: (file.progress / 100) * file.size
" :unit="file.status === 'completed' || file.status === 'error' ? '' : 'bytes'" :max="file.size"
:status="file.status" text-position="inside" size="20"
@@ -87,8 +87,8 @@
type="button"
@click="uploadManager.pause(file.id)"
class="action"
:aria-label="t('general.pause')"
:title="t('general.pause')"
:aria-label="$t('general.pause')"
:title="$t('general.pause')"
>
<i class="material-symbols">pause</i>
</button>
@@ -97,8 +97,8 @@
type="button"
@click="uploadManager.resume(file.id)"
class="action"
:aria-label="t('general.resume')"
:title="t('general.resume')"
:aria-label="$t('general.resume')"
:title="$t('general.resume')"
>
<i class="material-symbols">play_arrow</i>
</button>
@@ -107,8 +107,8 @@
type="button"
@click="uploadManager.retry(file.id)"
class="action"
:aria-label="t('general.retry')"
:title="t('general.retry')"
:aria-label="$t('general.retry')"
:title="$t('general.retry')"
>
<i class="material-symbols">replay</i>
</button>
@@ -117,8 +117,8 @@
type="button"
@click="handleConflictAction(file)"
class="action"
:aria-label="t('general.replace')"
:title="t('general.replace')"
:aria-label="$t('general.replace')"
:title="$t('general.replace')"
>
<i class="material-symbols">sync_problem</i>
</button>
@@ -126,8 +126,8 @@
type="button"
@click="uploadManager.cancel(file.id)"
class="action"
:aria-label="t('general.cancel')"
:title="t('general.cancel')"
:aria-label="$t('general.cancel')"
:title="$t('general.cancel')"
>
<i class="material-symbols">close</i>
</button>
@@ -144,30 +144,30 @@
type="button"
@click="uploadManager.pauseAll()"
class="button button--flat"
:aria-label="t('buttons.pauseAll')"
:title="t('buttons.pauseAll')"
:aria-label="$t('buttons.pauseAll')"
:title="$t('buttons.pauseAll')"
>
{{ t("buttons.pauseAll") }}
{{ $t("buttons.pauseAll") }}
</button>
<button
v-if="canResumeAll"
type="button"
@click="uploadManager.resumeAll()"
class="button button--flat"
:aria-label="t('buttons.resumeAll')"
:title="t('buttons.resumeAll')"
:aria-label="$t('buttons.resumeAll')"
:title="$t('buttons.resumeAll')"
>
{{ t("buttons.resumeAll") }}
{{ $t("buttons.resumeAll") }}
</button>
<button
type="button"
@click="uploadManager.clearCompleted()"
class="button button--flat"
:disabled="!hasClearable"
:aria-label="t('buttons.clearCompleted')"
:title="t('buttons.clearCompleted')"
:aria-label="$t('buttons.clearCompleted')"
:title="$t('buttons.clearCompleted')"
>
{{ t("buttons.clearCompleted") }}
{{ $t("buttons.clearCompleted") }}
</button>
</div>
@@ -181,13 +181,13 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch } from "vue";
import { useI18n } from "@/i18n/index.ts";
import { readAllDirectoryEntries, uploadManager } from "@/utils/upload.ts";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { readAllDirectoryEntries, uploadManager } from "@/utils/upload";
import type { UploadConflictResolution, UploadEntry, UploadItem, UploadStatus } from "@/utils/upload.ts";
import { getTypeInfo, getTypeInfoFromExt } from "@/utils/mimetype.js";
import { getters, mutations, state } from "@/store/index.ts";
import { notify } from "@/notify/index.ts";
import { getTypeInfo, getTypeInfoFromExt } from "@/utils/mimetype";
import { getters, mutations, state } from "@/store";
import { notify } from "@/notify";
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
import ProgressBar from "@/components/ProgressBar.vue";
import SettingsItem from "@/components/settings/SettingsItem.vue";
@@ -219,11 +219,11 @@ defineOptions({ name: "UploadFiles" });
const { t } = useI18n();
const fileInput = useTemplateRef<HTMLInputElement>("fileInput");
const folderInput = useTemplateRef<HTMLInputElement>("folderInput");
const cameraInput = useTemplateRef<HTMLInputElement>("cameraInput");
const videoInput = useTemplateRef<HTMLInputElement>("videoInput");
const list = useTemplateRef<HTMLElement>("list");
const fileInput = ref<HTMLInputElement | null>(null);
const folderInput = ref<HTMLInputElement | null>(null);
const cameraInput = ref<HTMLInputElement | null>(null);
const videoInput = ref<HTMLInputElement | null>(null);
const list = ref<HTMLElement | null>(null);
const files = computed(() => uploadManager.queue);
const isDragging = ref(false);
const showConflictPrompt = ref(false);
@@ -10,7 +10,7 @@
:config-locked-paths="lockedFromConfigPaths"
show-extension-inputs
:show-thumbnail-master="false"
@changed="onPreferenceSectionChange"
@change="onPreferenceSectionChange"
@enforced-change="onEnforcedChange"
/>
<UserDefaultsAccountSection
@@ -19,17 +19,17 @@
:enforced-permissions="enforced.account?.permissions || {}"
:config-locked-paths="lockedFromConfigPaths"
:show-require-password-change="passwordAccountDefaults"
@account-change="({ field, value }) => onAccountFieldChange(field, value)"
@enforced-change="({ field, value }) => patchEnforcedFlag('account', field, value)"
@enforced-permission-change="({ field, value }) => patchEnforcedFlag('account', `permissions.${field}`, value)"
@account-change="onAccountFieldChange"
@enforced-change="(field, value) => patchEnforcedFlag('account', field, value)"
@enforced-permission-change="(field, value) => patchEnforcedFlag('account', `permissions.${field}`, value)"
/>
</template>
</div>
</template>
<script>
import { notify } from "@/notify/index.ts";
import { getUserDefaults, patchUserDefaults } from "@/api/settings.js";
import { notify } from "@/notify";
import { getUserDefaults, patchUserDefaults } from "@/api/settings";
import { getObjectProperty } from "@/utils/object.js";
import UserProfilePreferences from "@/components/settings/UserProfilePreferences.vue";
import UserDefaultsAccountSection from "@/components/settings/UserDefaultsAccountSection.vue";
+13 -24
View File
@@ -121,7 +121,7 @@
</div>
<source-file-permissions
:permissions="sourcePermissionsFor(source.name)"
@changed="({ key, value }) => onSourcePermissionChange(source.name, key, value)"
@changed="(key, value) => onSourcePermissionChange(source.name, key, value)"
/>
<div class="scope-quota-block">
<ToggleSwitch
@@ -172,19 +172,19 @@
class="item"
:name="$t('settings.userEditPreferences')"
:description="$t('settings.userDefaultsDescription')"
@activate="openPreferencesPrompt"
@click="openPreferencesPrompt"
/>
<SettingsButton
class="item"
:name="$t('settings.userEditTools')"
:description="$t('settings.userEditToolsDescription')"
@activate="openToolsPrompt"
@click="openToolsPrompt"
/>
<SettingsButton
class="item"
:name="$t('settings.userEditSidebarLinks')"
:description="$t('sidebar.customizeLinksDescription')"
@activate="openSidebarLinksPrompt"
@click="openSidebarLinksPrompt"
/>
<UserGroupsField
class="item"
@@ -205,7 +205,7 @@
:enforced-permissions="enforcedAccountPermissions"
:show-require-password-change="user.loginMethod === 'password'"
respect-enforced-policy
@account-change="({ field, value }) => onEditAccountChange(field, value)"
@account-change="onEditAccountChange"
/>
</div>
</div>
@@ -226,8 +226,8 @@
</template>
<script>
import { mutations, state } from "@/store/index.ts";
import { usersApi, settingsApi, authApi, accessApi } from "@/api/index.js";
import { mutations, state } from "@/store";
import { usersApi, settingsApi, authApi, accessApi } from "@/api";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import SourceFilePermissions from "@/components/settings/SourceFilePermissions.vue";
import SettingsItem from "@/components/settings/SettingsItem.vue";
@@ -240,16 +240,16 @@ import PasswordRequirementsHint from "@/components/PasswordRequirementsHint.vue"
import UserGroupsField from "@/components/settings/UserGroupsField.vue";
import Errors from "@/views/Errors.vue";
import { evaluatePasswordPolicy } from "@/utils/passwordPolicy.js";
import { notify } from "@/notify/index.ts";
import { validateLogin } from "@/utils/auth.js";
import { globalVars } from "@/utils/constants.ts";
import { eventBus } from "@/store/eventBus.ts";
import { notify } from "@/notify";
import { validateLogin } from "@/utils/auth";
import { globalVars } from "@/utils/constants";
import { eventBus } from "@/store/eventBus";
import { getObjectProperty, setObjectProperty } from "@/utils/object.js";
import {
GB,
bytesFromCustomAmount,
customAmountFromBytes,
} from "@/utils/quotaUnits.js";
} from "@/utils/quotaUnits";
import {
sectionsFromFlatUser,
applySectionsToFlatUser,
@@ -336,16 +336,6 @@ export default {
permissions: { admin: false },
otpEnabled: false,
loginMethod: null, // Will be set based on available methods
// Placeholders: the whole object is replaced by the loaded/default user
lockPassword: false,
requirePasswordChange: false,
disableSettings: false,
disableUpdateNotifications: false,
showAdvancedProfile: false,
maxConcurrentTranscodes: 1,
toolAccess: {},
sidebarLinks: [],
passkeyCredentials: [],
},
showDelete: false,
createUserDir: false,
@@ -1159,14 +1149,13 @@ export default {
this.applyEditAccountField(field, value);
this.applyEditAccountToUser();
this.emitUpdate();
this.syncSessionState();
},
deletePrompt() {
mutations.showPrompt({
name: "generic",
props: {
title: this.$t("general.delete"),
body: this.$t("prompts.deleteUserMessage"),
body: this.$t("prompts.deleteUserMessage", { username: this.user.username }),
buttons: [
{
label: this.$t("general.delete"),
Loaded 100 of 248 files, more files were not shown because too many files have changed in this diff. Show more