Skip to content

Commit 31d49d6

Browse files
committed
feat: metadata editor
1 parent a6bc4cd commit 31d49d6

File tree

9 files changed

+284
-16
lines changed

9 files changed

+284
-16
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@vue/tsconfig": "^0.8.1",
3030
"@vueuse/core": "^13.7.0",
3131
"ajv": "^8.17.1",
32+
"ajv-formats": "^3.0.1",
3233
"axios": "^1.12.0",
3334
"datatransfer-files-promise": "^2.0.0",
3435
"es-toolkit": "^1.39.10",

src/components/FileUpload.vue

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import FileDropArea from "@/components/FileDropArea.vue";
55
import ProgressBar from "@/components/ProgressBar.vue";
66
import useMessenger from "@/message/messenger.composable";
77
import type { ProgressHandler } from "@/api/api.types";
8+
import { handleFileInput } from "@/util";
89
910
const props = defineProps<{
1011
/**
@@ -20,20 +21,6 @@ const props = defineProps<{
2021
const { clear } = useMessenger();
2122
const progress = ref<number>();
2223
23-
/** Call upload function when using the <input> element. */
24-
async function handleFileInput(event: Event) {
25-
const fileInput = event.target as HTMLInputElement;
26-
if (!fileInput.files) {
27-
throw new RangeError("No file found in the file input");
28-
}
29-
30-
// Convert from FileList to File[]
31-
const files = [...fileInput.files!];
32-
await handleUpload(files);
33-
// Empty the input value to enable selecting the same file again.
34-
fileInput.value = "";
35-
}
36-
3724
/** Call upload function. */
3825
async function handleUpload(files: File[]) {
3926
clear();
@@ -93,7 +80,7 @@ function onProgress(progressEvent: AxiosProgressEvent) {
9380
class="hidden"
9481
:multiple="multiple"
9582
:accept="accept"
96-
@change="handleFileInput"
83+
@change="handleFileInput($event, handleUpload)"
9784
/>
9885

9986
<ProgressBar

src/i18n/locales/en.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,17 @@ exports.help.more.label: Read more about export files in the user manual
126126
user.settings: Settings
127127
user.settings.admin_mode: Enable administration mode
128128
user.admin_mode.warning: Administration mode is enabled. Please be careful!
129+
metadata_editor: Metadata editor
130+
metadata_editor.help: This tool is meant to assist when editing resource metadata for the Språkbanken Text {repository}. You can only edit locally, not save anything directly to the repository. Please read more at the {info} page.
131+
metadata_editor.help.info.url: https://spraakbanken.gu.se/en/about/internal/technology/metadata
132+
metadata_editor.help.info.label: Resource metadata
133+
metadata_editor.help.repository.url: https://spraakbanken.gu.se/en/resources
134+
metadata_editor.help.repository.label: resource repository
135+
metadata_editor.load_file: Load file
136+
metadata_editor.load_template: Load template
137+
schema.validate.error: "Error at location {path}: {message}"
138+
schema.validate.ok: The file is valid
139+
schema.validate.validation: Validation
129140
errors: Errors
130141
warnings: Warnings
131142
error.message: There was an error
@@ -139,6 +150,7 @@ docs.url: https://spraakbanken.gu.se/en/tools/mink
139150
locale.switcher.label: Interface language
140151
en: English
141152
sv: Swedish
153+
file: File
142154
metadata: Metadata
143155
sources: Source files
144156
configuration: Configuration

src/i18n/locales/sv.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,17 @@ exports.help.more.label: Läs mer om exportfiler i användarhandledningen
127127
user.settings: Inställningar
128128
user.settings.admin_mode: Aktivera administratörsläget
129129
user.admin_mode.warning: Administratörsläget är aktiverat. Var försiktig!
130+
metadata_editor: Metadataredigeraren
131+
metadata_editor.help: Det här verktyget ska underlätta vid redigering av resursmetadata för Språkbanken Texts {repository}. Här kan du bara redigera lokalt, inte spara något direkt till katalogen. Läs mer under {info} på hemsidan.
132+
metadata_editor.help.info.url: https://spraakbanken.gu.se/om/internt/teknik/metadata
133+
metadata_editor.help.info.label: Resource metadata
134+
metadata_editor.help.repository.url: https://spraakbanken.gu.se/resurser
135+
metadata_editor.help.repository.label: resurskatalog
136+
metadata_editor.load_file: Ladda in fil
137+
metadata_editor.load_template: Ladda in mall
138+
schema.validate.error: "Fel vid position {path}: {message}"
139+
schema.validate.ok: Filen är giltig
140+
schema.validate.validation: Validering
130141
errors: Fel
131142
warnings: Varningar
132143
error.message: Ett fel uppstod
@@ -140,6 +151,7 @@ docs.url: https://spraakbanken.gu.se/verktyg/mink
140151
locale.switcher.label: Gränssnittsspråk
141152
en: Engelska
142153
sv: Svenska
154+
file: Fil
143155
metadata: Metadata
144156
sources: Källfiler
145157
configuration: Konfiguration
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
<script setup lang="ts">
2+
import Yaml from "js-yaml";
3+
import { ref, watch } from "vue";
4+
import {
5+
PhCheckCircle,
6+
PhEye,
7+
PhFileArrowUp,
8+
PhFloppyDisk,
9+
PhPencil,
10+
PhWarning,
11+
} from "@phosphor-icons/vue";
12+
import { useLocalStorage, useToggle, watchDebounced } from "@vueuse/core";
13+
import type { ErrorObject } from "ajv";
14+
import {
15+
loadTemplateMemoized,
16+
loadValidatorOnce,
17+
ResourceType,
18+
} from "./metadataEditor";
19+
import LayoutBox from "@/components/LayoutBox.vue";
20+
import HelpBox from "@/components/HelpBox.vue";
21+
import SyntaxHighlight from "@/components/SyntaxHighlight.vue";
22+
import useMessenger from "@/message/messenger.composable";
23+
import ActionButton from "@/components/ActionButton.vue";
24+
import PageTitle from "@/components/PageTitle.vue";
25+
import { downloadFile, handleFileInput, randomString } from "@/util";
26+
import FileDropArea from "@/components/FileDropArea.vue";
27+
28+
const { alertError } = useMessenger();
29+
const yaml = useLocalStorage<string>("mink-metadata-editor-yaml", "");
30+
const [isEditing, toggleEditing] = useToggle(true);
31+
32+
const fileInput = ref<HTMLInputElement>();
33+
const validationErrors = ref<ErrorObject[]>();
34+
const validatorPromise = loadValidatorOnce().catch(alertError);
35+
const selectedType = ref<ResourceType>();
36+
37+
/** Load file content as raw YAML for editing. */
38+
async function fileHandler(files: File[]): Promise<void> {
39+
yaml.value = await files[0]!.text();
40+
}
41+
42+
/** Validate current YAML content against our JSON schema */
43+
const validate = async () => {
44+
// Reset if no content.
45+
if (!yaml.value) {
46+
validationErrors.value = undefined;
47+
return;
48+
}
49+
50+
// Load YAML as data and validate it.
51+
const validator = (await validatorPromise)!;
52+
const data = Yaml.load(yaml.value, { schema: Yaml.JSON_SCHEMA });
53+
validator(data);
54+
55+
// Store any validation errors.
56+
validationErrors.value = validator.errors || [];
57+
};
58+
59+
/** Trigger download of current YAML content. */
60+
function save() {
61+
// TODO Let user edit the resource id.
62+
const name = randomString();
63+
downloadFile(yaml.value, `metadata_${name}.yaml`);
64+
}
65+
66+
// Validate while editing.
67+
watchDebounced(yaml, validate, { debounce: 200, immediate: true });
68+
69+
// When selecting a template type, load the template.
70+
watch(selectedType, async () => {
71+
if (selectedType.value) {
72+
yaml.value = await loadTemplateMemoized(selectedType.value);
73+
selectedType.value = undefined;
74+
}
75+
});
76+
</script>
77+
78+
<template>
79+
<div>
80+
<PageTitle>{{ $t("metadata_editor") }}</PageTitle>
81+
82+
<HelpBox class="my-4">
83+
<i18n-t keypath="metadata_editor.help" scope="global">
84+
<template #repository>
85+
<a :href="$t('metadata_editor.help.repository.url')" target="_blank">
86+
{{ $t("metadata_editor.help.repository.label") }}
87+
</a>
88+
</template>
89+
<template #info>
90+
<a :href="$t('metadata_editor.help.info.url')" target="_blank">
91+
{{ $t("metadata_editor.help.info.label") }}
92+
</a>
93+
</template>
94+
</i18n-t>
95+
</HelpBox>
96+
97+
<div class="flex flex-wrap gap-4 items-start">
98+
<LayoutBox class="w-xl grow" :title="$t('edit')">
99+
<!-- Toolbar -->
100+
<div class="my-2 flex items-baseline gap-4">
101+
<!-- Load local file -->
102+
<FileDropArea @drop="fileHandler">
103+
<ActionButton
104+
@click="fileInput?.click()"
105+
:class="{ 'button-primary': !yaml }"
106+
>
107+
<PhFileArrowUp class="inline mb-1 mr-1" />
108+
{{ $t("metadata_editor.load_file") }}
109+
</ActionButton>
110+
<input
111+
accept=".yaml,.yml"
112+
type="file"
113+
ref="fileInput"
114+
class="hidden"
115+
@change="handleFileInput($event, fileHandler)"
116+
/>
117+
</FileDropArea>
118+
119+
<!-- Load template -->
120+
<div>
121+
<select v-model="selectedType">
122+
<option :value="undefined" disabled>
123+
{{ $t("metadata_editor.load_template") }}
124+
</option>
125+
<option v-for="type in ResourceType" :key="type">
126+
{{ type }}
127+
</option>
128+
</select>
129+
</div>
130+
131+
<div class="flex-grow"></div>
132+
133+
<!-- Toggle: Edit/View -->
134+
<ActionButton v-if="isEditing" @click="toggleEditing(false)">
135+
<PhEye class="inline mb-0.5 mr-1" />
136+
{{ $t("show") }}
137+
</ActionButton>
138+
<ActionButton v-else @click="toggleEditing(true)">
139+
<PhPencil class="inline mb-0.5 mr-1" />
140+
{{ $t("edit") }}
141+
</ActionButton>
142+
143+
<!-- Save -->
144+
<ActionButton
145+
@click="save()"
146+
:class="{ 'button-primary': yaml && !validationErrors?.length }"
147+
>
148+
<PhFloppyDisk class="inline mb-0.5 mr-1" />
149+
{{ $t("save") }}
150+
</ActionButton>
151+
</div>
152+
153+
<!-- Either a textarea for editing... -->
154+
<SyntaxHighlight v-show="!isEditing" :code="yaml" language="yaml" />
155+
156+
<!-- ...or YAML output -->
157+
<textarea
158+
v-show="isEditing"
159+
v-model="yaml"
160+
class="w-full h-96 font-mono text-xs"
161+
></textarea>
162+
</LayoutBox>
163+
164+
<LayoutBox class="w-96 grow" :title="$t('schema.validate.validation')">
165+
<HelpBox v-if="validationErrors && !validationErrors.length">
166+
<PhCheckCircle class="inline mb-0.5 mr-1" />
167+
{{ $t("schema.validate.ok") }}
168+
</HelpBox>
169+
<!-- A message for each validation error -->
170+
<HelpBox
171+
v-for="error in validationErrors"
172+
:key="error.instancePath + error.keyword"
173+
important
174+
>
175+
<PhWarning class="inline mb-1 mr-1" />
176+
<i18n-t scope="global" keypath="schema.validate.error">
177+
<template #path>{{ error.instancePath }}</template>
178+
<template #message>{{ error.message }}</template>
179+
</i18n-t>
180+
<div v-if="error.params">
181+
<div v-for="(value, key) in error.params" :key="key">
182+
<strong>{{ key }}:</strong> {{ value }}
183+
</div>
184+
</div>
185+
</HelpBox>
186+
</LayoutBox>
187+
</div>
188+
</div>
189+
</template>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import Ajv2020 from "ajv/dist/2020";
2+
import { memoize, once } from "es-toolkit";
3+
import addFormats from "ajv-formats";
4+
5+
const SCHEMA_URL =
6+
"https://raw.githubusercontent.com/spraakbanken/metadata/refs/heads/main/schema/metadata.json";
7+
8+
export enum ResourceType {
9+
analysis = "analysis",
10+
collection = "collection",
11+
corpus = "corpus",
12+
lexicon = "lexicon",
13+
model = "model",
14+
utility = "utility",
15+
}
16+
17+
export async function loadValidator() {
18+
const response = await fetch(SCHEMA_URL);
19+
const schema = await response.json();
20+
const ajv = new Ajv2020();
21+
addFormats(ajv);
22+
return ajv.compile(schema);
23+
}
24+
25+
export const loadValidatorOnce = once(loadValidator);
26+
27+
export async function loadTemplate(type: ResourceType) {
28+
const url = `https://raw.githubusercontent.com/spraakbanken/metadata/refs/heads/main/yaml_templates/${type}.yaml`;
29+
const response = await fetch(url);
30+
const json = await response.text();
31+
return json;
32+
}
33+
34+
export const loadTemplateMemoized = memoize(loadTemplate);

src/router/main.routes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import AccessDenied from "@/auth/AccessDenied.vue";
1111
import NotFound from "@/page/NotFound.vue";
1212
import corpusRoutes from "@/router/corpus.routes";
1313
import metadataRoutes from "@/router/metadata.routes";
14+
const MetadataEditorView = () =>
15+
import("@/metadata_editor/MetadataEditorView.vue");
1416

1517
const routes: RouteRecordRaw[] = [
1618
{
@@ -65,6 +67,11 @@ const routes: RouteRecordRaw[] = [
6567
},
6668
...corpusRoutes,
6769
...metadataRoutes,
70+
{
71+
path: "/metadata-editor",
72+
component: MetadataEditorView,
73+
meta: { title: "metadata_editor" },
74+
},
6875
{
6976
path: "/:pathMatch(.*)*",
7077
name: "notfound",

src/util.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,25 @@ export function addDays(date: Date, days: number) {
1919

2020
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
2121

22+
/** Get files from an `<input type="file">` element, pass them to a handler, and unless the handling fails, empty the input. */
23+
export async function handleFileInput(
24+
event: Event,
25+
fileHandler: (files: File[]) => Promise<void>,
26+
): Promise<void> {
27+
// Get content as FileList
28+
const fileInput = event.target as HTMLInputElement;
29+
if (!fileInput.files) throw new RangeError("No file found in the file input");
30+
31+
// Convert from FileList to File[]
32+
const files = [...fileInput.files];
33+
34+
// Delegate to handler
35+
await fileHandler(files);
36+
37+
// Upon success, empty the input value to enable selecting the same file again.
38+
fileInput.value = "";
39+
}
40+
2241
/** Trigger a file download in the browser by adding a temporary link and click it */
2342
export function downloadFile(data: string | Blob, filename: string) {
2443
// The url is temporary and bound to the window and document, and represents (does not contain) the data.

yarn.lock

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1610,6 +1610,13 @@ acorn@^8.15.0:
16101610
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
16111611
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
16121612

1613+
ajv-formats@^3.0.1:
1614+
version "3.0.1"
1615+
resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578"
1616+
integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==
1617+
dependencies:
1618+
ajv "^8.0.0"
1619+
16131620
ajv@^6.12.4:
16141621
version "6.12.6"
16151622
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
@@ -1620,7 +1627,7 @@ ajv@^6.12.4:
16201627
json-schema-traverse "^0.4.1"
16211628
uri-js "^4.2.2"
16221629

1623-
ajv@^8.17.1:
1630+
ajv@^8.0.0, ajv@^8.17.1:
16241631
version "8.17.1"
16251632
resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6"
16261633
integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==

0 commit comments

Comments
 (0)