diff --git a/.github/workflows/validate-plugins.yml b/.github/workflows/validate-plugins.yml index a122825..defdc6c 100644 --- a/.github/workflows/validate-plugins.yml +++ b/.github/workflows/validate-plugins.yml @@ -28,10 +28,13 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup Biome - uses: biomejs/setup-biome@v2 + - name: Setup Node.js + uses: actions/setup-node@v4 with: - version: latest + node-version: 20 + + - name: Install dependencies + run: yarn install --frozen-lockfile - - name: Run Biome - run: biome ci . + - name: Run Linter + run: yarn lint diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..39abe4d --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +yarn lint diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..1cf7ac1 --- /dev/null +++ b/biome.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "files": { + "ignore": ["node_modules/**", "dist/**", "**/tsconfig*"], + "maxSize": 3670016 + }, + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true, + "defaultBranch": "develop" + }, + "formatter": { + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 120, + "formatWithErrors": true + }, + "css": { + "linter": { + "enabled": false + }, + "formatter": { + "enabled": false + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + } +} diff --git a/manifest.json b/manifest.json index 0ce8f84..10de59a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,80 +1,86 @@ [ - { - "title": "Bulk labeling for text spans with keyboard shortcuts", - "description": "Assigns labels to all occurrences of the selected text at once and removes them", - "path": "bulk-labeling", - "private": false - }, - { - "title": "Text area word count", - "description": "Validates the length of a text area based on its word count", - "path": "count-words-in-textarea", - "private": false - }, - { - "title": "Connect to LLM backend", - "description": "Sends prompts to an LLM URL", - "path": "llm-backend", - "private": false - }, - { - "title": "Markdown to HTML", - "description": "Converts markdown to HTML format", - "path": "markdown-to-html", - "private": false - }, - { - "title": "Text span overlap validation", - "description": "Prevents overlap of text spans", - "path": "ner-text-span-overlap-validation", - "private": false - }, - { - "title": "Spam and bot detection", - "description": "Pauses an annotator if bot behavior in detected", - "path": "pausing-annotator", - "private": false - }, - { - "title": "Data visualization", - "description": "Display a Plotly chart to annotators", - "path": "plotly", - "private": false - }, - { - "title": "Redact annotator PII", - "description": "Anonymize the annotator to reduce bias", - "path": "redact-pii", - "private": false - }, - { - "title": "Dynamic image swap", - "description": "Changes active image based on conditions", - "path": "different-images-per-label", - "private": false - }, - { - "title": "Spellcheck", - "description": "Validates misspelled text", - "path": "spellcheck", - "private": false - }, - { - "title": "Validate JSON", - "description": "Checks that the introduced text is a valid JSON", - "path": "validate-json-in-textarea", - "private": false - }, - { - "title": "Simple content moderation", - "description": "Prevents saving annotations containing inappropriate content", - "path": "simple-content-moderation", - "private": false - }, - { - "title": "Multi-frame video view", - "description": "Synchronizes multiple video views to display a video with different frame offsets", - "path": "multi-frame-video-view", - "private": false - } + { + "title": "Bulk labeling for text spans with keyboard shortcuts", + "description": "Assigns labels to all occurrences of the selected text at once and removes them", + "path": "bulk-labeling", + "private": false + }, + { + "title": "Text area word count", + "description": "Validates the length of a text area based on its word count", + "path": "count-words-in-textarea", + "private": false + }, + { + "title": "Connect to LLM backend", + "description": "Sends prompts to an LLM URL", + "path": "llm-backend", + "private": false + }, + { + "title": "Markdown to HTML", + "description": "Converts markdown to HTML format", + "path": "markdown-to-html", + "private": false + }, + { + "title": "Text span overlap validation", + "description": "Prevents overlap of text spans", + "path": "ner-text-span-overlap-validation", + "private": false + }, + { + "title": "Spam and bot detection", + "description": "Pauses an annotator if bot behavior in detected", + "path": "pausing-annotator", + "private": false + }, + { + "title": "Data visualization", + "description": "Display a Plotly chart to annotators", + "path": "plotly", + "private": false + }, + { + "title": "Redact annotator PII", + "description": "Anonymize the annotator to reduce bias", + "path": "redact-pii", + "private": false + }, + { + "title": "Dynamic image swap", + "description": "Changes active image based on conditions", + "path": "different-images-per-label", + "private": false + }, + { + "title": "Spellcheck", + "description": "Validates misspelled text", + "path": "spellcheck", + "private": false + }, + { + "title": "Validate JSON", + "description": "Checks that the introduced text is a valid JSON", + "path": "validate-json-in-textarea", + "private": false + }, + { + "title": "Simple content moderation", + "description": "Prevents saving annotations containing inappropriate content", + "path": "simple-content-moderation", + "private": false + }, + { + "title": "Multi-frame video view", + "description": "Synchronizes multiple video views to display a video with different frame offsets", + "path": "multi-frame-video-view", + "private": false + }, + { + "title": "Dynamic text tags from textarea", + "description": "Dynamically populates a text box given the input of a text area tag for span based labeling", + "path": "dynamic-text-spans", + "private": false + } ] diff --git a/package.json b/package.json new file mode 100644 index 0000000..759d6c7 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "label-studio-plugins", + "version": "1.0.0", + "description": "Plugins to extend Label Studio with custom workflows, integrations, and UI components.", + "main": "index.js", + "repository": "git@github.com:HumanSignal/label-studio-plugins.git", + "author": "Ignacio Velazquez ", + "license": "MIT", + "private": true, + "scripts": { + "lint": "biome check --write .", + "prepare": "husky" + }, + "dependencies": {}, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "husky": "^9.1.7" + } +} diff --git a/src/bulk-labeling/data.json b/src/bulk-labeling/data.json index aad9068..13dff1b 100644 --- a/src/bulk-labeling/data.json +++ b/src/bulk-labeling/data.json @@ -1,10 +1,10 @@ { - "data": { - "text": [ - "Opossums, commonly known as possums in North America, are marsupials found primarily in the Americas. The most well-known species is the Virginia opossum (Didelphis virginiana), which ranges from Central America and the eastern United States to southern Canada. These adaptable creatures are known for their ability to thrive in a variety of environments, including both rural and urban areas. Opossums are also found in South America, where different species inhabit a range of ecosystems, from tropical rainforests to temperate forests.", - "Opossums are highly adaptable in terms of habitat, often residing in woodlands, farmland, and even suburban backyards. They typically seek shelter in hollow trees, abandoned burrows, or any dark, enclosed space they can find. Opossums are nocturnal and omnivorous, with a diet that includes fruits, insects, small animals, and even carrion. Their opportunistic feeding habits contribute to their resilience and ability to live in close proximity to human settlements.", - "In terms of behavior, opossums are solitary and nomadic, often moving to different locations in search of food. They are known for their unique defense mechanism of 'playing dead' or 'playing possum' when threatened, which involves mimicking the appearance and smell of a sick or dead animal to deter predators. Opossums have relatively short lifespans, typically living only 2 to 4 years in the wild. Despite their short lives, they reproduce quickly, with females giving birth to large litters of up to 20 young, although not all offspring typically survive to maturity.", - "In popular culture, opossums often appear as symbols of resilience and survival due to their hardy nature and ability to adapt to various environments. They are sometimes depicted in a comical or misunderstood light, given their nocturnal habits and somewhat disheveled appearance. Despite this, they play a crucial role in the ecosystem by controlling insect and rodent populations and cleaning up carrion. Opossums have been featured in various forms of media, from cartoons and children's books to movies, often emphasizing their unique behaviors and survival strategies." - ] - } + "data": { + "text": [ + "Opossums, commonly known as possums in North America, are marsupials found primarily in the Americas. The most well-known species is the Virginia opossum (Didelphis virginiana), which ranges from Central America and the eastern United States to southern Canada. These adaptable creatures are known for their ability to thrive in a variety of environments, including both rural and urban areas. Opossums are also found in South America, where different species inhabit a range of ecosystems, from tropical rainforests to temperate forests.", + "Opossums are highly adaptable in terms of habitat, often residing in woodlands, farmland, and even suburban backyards. They typically seek shelter in hollow trees, abandoned burrows, or any dark, enclosed space they can find. Opossums are nocturnal and omnivorous, with a diet that includes fruits, insects, small animals, and even carrion. Their opportunistic feeding habits contribute to their resilience and ability to live in close proximity to human settlements.", + "In terms of behavior, opossums are solitary and nomadic, often moving to different locations in search of food. They are known for their unique defense mechanism of 'playing dead' or 'playing possum' when threatened, which involves mimicking the appearance and smell of a sick or dead animal to deter predators. Opossums have relatively short lifespans, typically living only 2 to 4 years in the wild. Despite their short lives, they reproduce quickly, with females giving birth to large litters of up to 20 young, although not all offspring typically survive to maturity.", + "In popular culture, opossums often appear as symbols of resilience and survival due to their hardy nature and ability to adapt to various environments. They are sometimes depicted in a comical or misunderstood light, given their nocturnal habits and somewhat disheveled appearance. Despite this, they play a crucial role in the ecosystem by controlling insect and rodent populations and cleaning up carrion. Opossums have been featured in various forms of media, from cartoons and children's books to movies, often emphasizing their unique behaviors and survival strategies." + ] + } } diff --git a/src/bulk-labeling/plugin.js b/src/bulk-labeling/plugin.js index 6bcff91..57d4c77 100644 --- a/src/bulk-labeling/plugin.js +++ b/src/bulk-labeling/plugin.js @@ -7,95 +7,89 @@ let isShiftKeyPressed = false; window.addEventListener("keydown", (e) => { - if (e.key === "Shift") { - isShiftKeyPressed = true; - } + if (e.key === "Shift") { + isShiftKeyPressed = true; + } }); window.addEventListener("keyup", (e) => { - if (e.key === "Shift") { - isShiftKeyPressed = false; - } + if (e.key === "Shift") { + isShiftKeyPressed = false; + } }); LSI.on("entityDelete", (region) => { - if (!isShiftKeyPressed) return; // Only proceed if the shift key is pressed - - if (window.BULK_REGIONS) return; - window.BULK_REGIONS = true; - setTimeout(() => { - window.BULK_REGIONS = false; - }, 1000); - - const existingEntities = Htx.annotationStore.selected.regions; - const regionsToDelete = existingEntities.filter((entity) => { - const deletedText = region.text.toLowerCase().replace("\\\\n", " "); - const otherText = entity.text.toLowerCase().replace("\\\\n", " "); - return deletedText === otherText && region.labels[0] === entity.labels[0]; - }); - - for (const region of regionsToDelete) { - Htx.annotationStore.selected.deleteRegion(region); - } - - Htx.annotationStore.selected.updateObjects(); + if (!isShiftKeyPressed) return; // Only proceed if the shift key is pressed + + if (window.BULK_REGIONS) return; + window.BULK_REGIONS = true; + setTimeout(() => { + window.BULK_REGIONS = false; + }, 1000); + + const existingEntities = Htx.annotationStore.selected.regions; + const regionsToDelete = existingEntities.filter((entity) => { + const deletedText = region.text.toLowerCase().replace("\\\\n", " "); + const otherText = entity.text.toLowerCase().replace("\\\\n", " "); + return deletedText === otherText && region.labels[0] === entity.labels[0]; + }); + + for (const region of regionsToDelete) { + Htx.annotationStore.selected.deleteRegion(region); + } + + Htx.annotationStore.selected.updateObjects(); }); LSI.on("entityCreate", (region) => { - if (!isShiftKeyPressed) return; - - if (window.BULK_REGIONS) return; - window.BULK_REGIONS = true; - setTimeout(() => { - window.BULK_REGIONS = false; - }, 1000); - - const existingEntities = Htx.annotationStore.selected.regions; - - setTimeout(() => { - // Prevent tagging a single character - if (region.text.length < 2) return; - regexp = new RegExp( - region.text.replace("\\\\n", "\\\\s+").replace(" ", "\\\\s+"), - "gi", - ); - const matches = Array.from(region.object._value.matchAll(regexp)); - for (const match of matches) { - if (match.index === region.startOffset) continue; - - const startOffset = match.index; - const endOffset = match.index + region.text.length; - - // Check for existing entities with overlapping start and end offset - let isDuplicate = false; - for (const entity of existingEntities) { - if ( - startOffset <= entity.globalOffsets.end && - entity.globalOffsets.start <= endOffset - ) { - isDuplicate = true; - break; - } - } - - if (!isDuplicate) { - Htx.annotationStore.selected.createResult( - { - text: region.text, - start: "/span[1]/text()[1]", - startOffset: startOffset, - end: "/span[1]/text()[1]", - endOffset: endOffset, - }, - { - labels: [...region.labeling.value.labels], - }, - region.labeling.from_name, - region.object, - ); - } - } - - Htx.annotationStore.selected.updateObjects(); - }, 100); + if (!isShiftKeyPressed) return; + + if (window.BULK_REGIONS) return; + window.BULK_REGIONS = true; + setTimeout(() => { + window.BULK_REGIONS = false; + }, 1000); + + const existingEntities = Htx.annotationStore.selected.regions; + + setTimeout(() => { + // Prevent tagging a single character + if (region.text.length < 2) return; + regexp = new RegExp(region.text.replace("\\\\n", "\\\\s+").replace(" ", "\\\\s+"), "gi"); + const matches = Array.from(region.object._value.matchAll(regexp)); + for (const match of matches) { + if (match.index === region.startOffset) continue; + + const startOffset = match.index; + const endOffset = match.index + region.text.length; + + // Check for existing entities with overlapping start and end offset + let isDuplicate = false; + for (const entity of existingEntities) { + if (startOffset <= entity.globalOffsets.end && entity.globalOffsets.start <= endOffset) { + isDuplicate = true; + break; + } + } + + if (!isDuplicate) { + Htx.annotationStore.selected.createResult( + { + text: region.text, + start: "/span[1]/text()[1]", + startOffset: startOffset, + end: "/span[1]/text()[1]", + endOffset: endOffset, + }, + { + labels: [...region.labeling.value.labels], + }, + region.labeling.from_name, + region.object, + ); + } + } + + Htx.annotationStore.selected.updateObjects(); + }, 100); }); diff --git a/src/count-words-in-textarea/data.json b/src/count-words-in-textarea/data.json index 12ad194..e90216d 100644 --- a/src/count-words-in-textarea/data.json +++ b/src/count-words-in-textarea/data.json @@ -1,5 +1,5 @@ { - "data": { - "text": "The quick brown fox jumps over the lazy dog." - } + "data": { + "text": "The quick brown fox jumps over the lazy dog." + } } diff --git a/src/count-words-in-textarea/plugin.js b/src/count-words-in-textarea/plugin.js index b916b58..50554fe 100644 --- a/src/count-words-in-textarea/plugin.js +++ b/src/count-words-in-textarea/plugin.js @@ -4,23 +4,19 @@ let dismissed = false; -LSI.on("beforeSaveAnnotation", (store, annotation) => { - const textAreaResult = annotation.results.find( - (r) => r.type === "textarea" && r.from_name.name === "textarea", - ); +LSI.on("beforeSaveAnnotation", (_store, annotation) => { + const textAreaResult = annotation.results.find((r) => r.type === "textarea" && r.from_name.name === "textarea"); - if (textAreaResult) { - words = textAreaResult.value.text[0]; - word_count = words.split(" ").length; + if (textAreaResult) { + words = textAreaResult.value.text[0]; + word_count = words.split(" ").length; - if (word_count > 10) { - Htx.showModal( - `Word count is ${word_count}. Please reduce to 10 or less.`, - ); - dismissed = true; - return false; // Block submission - } - } + if (word_count > 10) { + Htx.showModal(`Word count is ${word_count}. Please reduce to 10 or less.`); + dismissed = true; + return false; // Block submission + } + } - return true; // Allow submission + return true; // Allow submission }); diff --git a/src/different-images-per-label/data.json b/src/different-images-per-label/data.json index 0d7ade5..224d388 100644 --- a/src/different-images-per-label/data.json +++ b/src/different-images-per-label/data.json @@ -1,5 +1,5 @@ { - "data": { - "image": "/static/plugins/src/different-images-per-label/img/demo-sample.png" - } + "data": { + "image": "/static/plugins/src/different-images-per-label/img/demo-sample.png" + } } diff --git a/src/different-images-per-label/plugin.js b/src/different-images-per-label/plugin.js index a2f6d8d..d97e1df 100644 --- a/src/different-images-per-label/plugin.js +++ b/src/different-images-per-label/plugin.js @@ -7,44 +7,42 @@ const IMG_ID = "img_uniq"; // TODO: use your own keys and values here for label lookup and data objects to display const imagesRoot = "/static/plugins/src/different-images-per-label/img"; const images = { - Addressee: `${imagesRoot}/demo-addressee.jpg`, - "Account number": `${imagesRoot}/demo-routing-number.png`, - "Routing number": `${imagesRoot}/demo-routing-number.png`, - Signature: `${imagesRoot}/demo-sign.jpg`, - Amount: `${imagesRoot}/demo-amount.jpg`, - Watermark: `${imagesRoot}/demo-watermark.png`, - Date: `${imagesRoot}/demo-date.png`, - Correction: `${imagesRoot}/demo-correction.jpg`, + Addressee: `${imagesRoot}/demo-addressee.jpg`, + "Account number": `${imagesRoot}/demo-routing-number.png`, + "Routing number": `${imagesRoot}/demo-routing-number.png`, + Signature: `${imagesRoot}/demo-sign.jpg`, + Amount: `${imagesRoot}/demo-amount.jpg`, + Watermark: `${imagesRoot}/demo-watermark.png`, + Date: `${imagesRoot}/demo-date.png`, + Correction: `${imagesRoot}/demo-correction.jpg`, }; function appendCheckImg() { - let imageEl = window[IMG_ID]; - if (!imageEl) { - imageEl = document.createElement("img"); - imageEl.id = IMG_ID; + let imageEl = window[IMG_ID]; + if (!imageEl) { + imageEl = document.createElement("img"); + imageEl.id = IMG_ID; - const labelingInterface = document.querySelector( - ".lsf-main-view__annotation", - ); - if (labelingInterface) { - labelingInterface.insertAdjacentElement("beforeend", imageEl); - } else { - console.error("Labeling interface element not found."); - } - } + const labelingInterface = document.querySelector(".lsf-main-view__annotation"); + if (labelingInterface) { + labelingInterface.insertAdjacentElement("beforeend", imageEl); + } else { + console.error("Labeling interface element not found."); + } + } - // `label` is an actual tag name from config - const labels = LSI.annotation.names.get("label").children; + // `label` is an actual tag name from config + const labels = LSI.annotation.names.get("label").children; - // If you will have more Labels in a future adjust the logic - document.querySelectorAll(".lsf-label_clickable").forEach((lbl, index) => - lbl.addEventListener("click", () => { - const src = images[labels[index].value]; + // If you will have more Labels in a future adjust the logic + document.querySelectorAll(".lsf-label_clickable").forEach((lbl, index) => + lbl.addEventListener("click", () => { + const src = images[labels[index].value]; - // if there are no images with this key image will just have an empty src - imageEl.src = src; - }), - ); + // if there are no images with this key image will just have an empty src + imageEl.src = src; + }), + ); } appendCheckImg(); diff --git a/src/dynamic-text-spans/data.json b/src/dynamic-text-spans/data.json new file mode 100644 index 0000000..74e8e70 --- /dev/null +++ b/src/dynamic-text-spans/data.json @@ -0,0 +1,7 @@ +{ + "data": { + "audio": "https://data.heartex.net/librispeech/dev-clean/3536/8226/3536-8226-0024.flac.wav", + "transcription": "", + "text": "this is a test" + } +} diff --git a/src/dynamic-text-spans/plugin.js b/src/dynamic-text-spans/plugin.js new file mode 100644 index 0000000..e7c32e1 --- /dev/null +++ b/src/dynamic-text-spans/plugin.js @@ -0,0 +1,29 @@ +const TEXTAREA_NAME = "transcription"; +const TEXT_NAME = "extraction_text"; + +const current_annotation = LSI.annotation.id; +(function init() { + // textarea results have names like "tag_name:h4$h", so we use ^= + let $ta = document.querySelector(`textarea[name^="${TEXTAREA_NAME}"]`); + if (!$ta) return setTimeout(init, 300); + if ($ta._already_loaded) return; + + $ta._already_loaded = true; + (function tick() { + // it can be another textarea with submitted result + if (!$ta?.isConnected) { + $ta = document.querySelector(`textarea[name^="${TEXTAREA_NAME}"]`); + } + // if we lost textarea/moved out of current annotation — break the cycle + if (!$ta?.isConnected || LSI.annotation?.id !== current_annotation) return; + setTimeout(tick, 300); + + const textarea = LSI.annotation.names.get(TEXTAREA_NAME); + const text = LSI.annotation.names.get(TEXT_NAME); + + const val = String($ta.value ?? textarea.result?.value.text ?? ""); + if (text._value !== val) { + text.setRemoteValue(val); + } + })(); +})(); diff --git a/src/dynamic-text-spans/view.xml b/src/dynamic-text-spans/view.xml new file mode 100644 index 0000000..4b9b3ed --- /dev/null +++ b/src/dynamic-text-spans/view.xml @@ -0,0 +1,45 @@ + +
+