Compare commits

...

6 Commits

Author SHA1 Message Date
Alexander Klee d991bef7ab docs: update feature list in README 2024-05-15 20:20:57 +02:00
Alexander Klee 8b3f840c87 feat: add action for viewing problem statements 2024-05-15 20:20:57 +02:00
Alexander Klee 09355188b1 feat: implement PDF handling to prepare for in-IDE statement viewing 2024-05-15 20:20:57 +02:00
Alexander Klee 217979e9d7 chore: implement some IO utilities 2024-05-15 20:20:57 +02:00
Alexander Klee 9936fa3f78 style: move ConsoleUtil out of the ui package 2024-05-15 20:20:57 +02:00
Alexander Klee a819f9dd72 fix: avoid potential NPE 2024-05-15 20:20:57 +02:00
11 changed files with 371 additions and 11 deletions

View File

@ -1,13 +1,21 @@
# S-DOM
S-DOM is a plugin for IntelliJ IDEA that allows you to submit your code to the DOMjudge system directly from the IDE.
It isn't a full replacement for the web interface yet, but submitting files and reading basic results already works better than with that.
It isn't a full replacement for the web interface yet, but already matches or surpasses the web UI in most tasks.
## TODO
## Features
- Simple, one-click submissions and feedback (no need for File Explorer or reloading!)
- View problems and problem statements without leaving your IDE
- Unobtrusive and integrated
## Potential future features
- Support detecting the language of files (don't just force C++)
- Implement reading detailed results
- Implement reading problems (and testcases)
- Show scoreboards
- Maybe even download test cases and scaffold sources
## References
- [DOMjudge](https://www.domjudge.org/documentation)
- [DOMjudge API](https://domjudge.iti.kit.edu/main/api/doc)
- [SimpleCodeTester plugin](https://github.com/Mr-Pine/SimpleCodeTester-IntelliJ-Plugin)
- [Example Route](https://domjudge.iti.kit.edu/main/api/v4/contests/5/problems)
- [Example Route](https://domjudge.iti.kit.edu/main/api/v4/contests/5/problems)
- [All IDEA UI Icons](https://jetbrains.design/intellij/resources/icons_list/)

View File

@ -18,11 +18,9 @@ import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import java.io.ByteArrayOutputStream
import java.util.Base64
@ -164,10 +162,10 @@ object SDom {
}
suspend fun getProblems() {
if (currentContest == null) return
val contest = currentContest ?: return
val result = authenticatedClient.get(SDCredentials.url) {
url {
appendPathSegments("contests", currentContest!!.id, "problems")
appendPathSegments("contests", contest.id, "problems")
}
}
if (result.status.isSuccess()) {
@ -179,13 +177,24 @@ object SDom {
}
judgementTypes = authenticatedClient.get(SDCredentials.url) {
url {
appendPathSegments("contests", currentContest!!.id, "judgement-types")
appendPathSegments("contests", contest.id, "judgement-types")
}
}
.body<List<SDJudgementType>>()
.associateBy { it.id }
}
suspend fun downloadProblemStatement(contest: Contest, problem: Problem): ByteArray? {
val response: ByteArray = authenticatedClient.get(SDCredentials.url) {
url {
appendPathSegments("contests", contest.id, "problems", problem.id, "statement")
}
contentType(ContentType.Application.Pdf)
}.body()
return response
}
suspend fun submitSolution(
contest: Contest, problem: Problem, solution: String, fileName: String
): SharedFlow<Result<SDJudgement>> = coroutineScope {

View File

@ -0,0 +1,72 @@
package io.gitlab.jfronny.sdom.actions
import com.intellij.notification.NotificationGroupManager
import com.intellij.notification.NotificationType
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.ui.jcef.JBCefApp
import io.gitlab.jfronny.sdom.SDom
import io.gitlab.jfronny.sdom.SDom.currentContest
import io.gitlab.jfronny.sdom.SDom.currentProblem
import io.gitlab.jfronny.sdom.ui.ByteVirtualFile
import io.gitlab.jfronny.sdom.util.OSUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.nio.file.Files
class SDStatementAction(name: String) : DumbAwareAction(name) {
constructor() : this("")
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
override fun actionPerformed(e: AnActionEvent) {
val contest = currentContest
val problem = currentProblem
if (contest == null) {
NotificationGroupManager.getInstance()
.getNotificationGroup("sdom.notifications")
.createNotification("You have to select a contest", NotificationType.ERROR)
.setTitle("No contest selected")
.addAction(SDStatementAction("Retry"))
.notify(e.project)
return
}
if (problem == null) {
NotificationGroupManager.getInstance()
.getNotificationGroup("sdom.notifications")
.createNotification("You have to select a problem", NotificationType.ERROR)
.setTitle("No problem selected")
.addAction(SDStatementAction("Retry"))
.notify(e.project)
return
}
CoroutineScope(Job() + Dispatchers.IO).launch {
val data = SDom.downloadProblemStatement(contest, problem)
if (data == null) {
NotificationGroupManager.getInstance()
.getNotificationGroup("sdom.notifications")
.createNotification("This problem lacks a problem statement", NotificationType.ERROR)
.setTitle("No problem statement")
.addAction(SDStatementAction("Retry"))
.notify(e.project)
return@launch
}
if (JBCefApp.isSupported()) {
val virtualFile = ByteVirtualFile("${problem.name}_${contest.name}.pdf", data)
ApplicationManager.getApplication().invokeLater {
FileEditorManager.getInstance(e.project!!).openFile(virtualFile)
}
} else {
val path = Files.createTempFile("sdom", ".pdf")
Files.newOutputStream(path).use { it.write(data) }
OSUtils.openFile(path.toFile())
}
}
}
}

View File

@ -0,0 +1,21 @@
package io.gitlab.jfronny.sdom.ui
import com.intellij.openapi.fileTypes.FileType
import com.intellij.testFramework.LightVirtualFileBase
import com.intellij.util.LocalTimeCounter
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.io.OutputStream
class ByteVirtualFile(name: String, fileType: FileType?, modificationStamp: Long, private val content: ByteArray) : LightVirtualFileBase(name, fileType, modificationStamp) {
constructor() : this("")
constructor(name: String) : this(name, ByteArray(0))
constructor(name: String, content: ByteArray) : this(name, null, LocalTimeCounter.currentTime(), content)
constructor(name: String, fileType: FileType?, content: ByteArray) : this(name, fileType, LocalTimeCounter.currentTime(), content)
override fun getOutputStream(requestor: Any?, newModificationStamp: Long, newTimeStamp: Long): OutputStream = ByteArrayOutputStream()
override fun contentsToByteArray(): ByteArray = this.content.copyOf()
override fun getInputStream(): InputStream = ByteArrayInputStream(content)
override fun getLength(): Long = content.size.toLong()
}

View File

@ -0,0 +1,68 @@
package io.gitlab.jfronny.sdom.ui
import com.intellij.openapi.fileEditor.FileEditor
import com.intellij.openapi.fileEditor.FileEditorLocation
import com.intellij.openapi.fileEditor.FileEditorState
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.UserDataHolderBase
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.ui.jcef.JBCefBrowser
import com.intellij.ui.jcef.JBCefBrowserBuilder
import com.intellij.ui.jcef.JBCefClient
import io.gitlab.jfronny.sdom.util.pdfBootstrap
import org.jetbrains.annotations.Nls
import java.beans.PropertyChangeListener
import javax.swing.JComponent
class SDBrowserViewer(private val file: VirtualFile, cefClient: JBCefClient) : UserDataHolderBase(), FileEditor {
private val browser: JBCefBrowser = JBCefBrowserBuilder().setClient(cefClient)
.setEnableOpenDevToolsMenuItem(false)
.build()
init {
browser.loadHTML(pdfBootstrap(file.contentsToByteArray()))
}
private val component = browser.component
override fun getComponent(): JComponent {
return component
}
override fun getPreferredFocusedComponent(): JComponent? {
return component
}
override fun getName(): @Nls(capitalization = Nls.Capitalization.Title) String {
return "S-dom Integrated Statement Viewer"
}
override fun setState(fileEditorState: FileEditorState) {
}
override fun isModified(): Boolean {
return false
}
override fun isValid(): Boolean {
return file.isValid
}
override fun addPropertyChangeListener(propertyChangeListener: PropertyChangeListener) {
}
override fun removePropertyChangeListener(propertyChangeListener: PropertyChangeListener) {
}
override fun getCurrentLocation(): FileEditorLocation? {
return null
}
override fun dispose() {
Disposer.dispose(this)
}
override fun getFile(): VirtualFile? {
return this.file
}
}

View File

@ -0,0 +1,41 @@
package io.gitlab.jfronny.sdom.ui
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.fileEditor.FileEditor
import com.intellij.openapi.fileEditor.FileEditorPolicy
import com.intellij.openapi.fileEditor.FileEditorProvider
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.testFramework.LightVirtualFileBase
import com.intellij.ui.jcef.JBCefApp
import org.jetbrains.annotations.NonNls
class SDBrowserViewerProvider : FileEditorProvider, DumbAware {
private val ourCefClient = JBCefApp.getInstance().createClient()
init {
Disposer.register(ApplicationManager.getApplication(), ourCefClient)
}
override fun accept(project: Project, virtualFile: VirtualFile): Boolean {
return virtualFile is LightVirtualFileBase && virtualFile.extension == "pdf"
}
override fun createEditor(project: Project, virtualFile: VirtualFile): FileEditor {
return SDBrowserViewer(virtualFile, ourCefClient)
}
override fun getEditorTypeId(): @NonNls String {
return EDITOR_TYPE_ID
}
override fun getPolicy(): FileEditorPolicy {
return FileEditorPolicy.HIDE_DEFAULT_EDITOR
}
companion object {
private const val EDITOR_TYPE_ID = "SdomBrowserViewer"
}
}

View File

@ -9,6 +9,8 @@ import io.gitlab.jfronny.sdom.SDom
import io.gitlab.jfronny.sdom.model.Contest
import io.gitlab.jfronny.sdom.model.Problem
import io.gitlab.jfronny.sdom.model.SDJudgement
import io.gitlab.jfronny.sdom.util.OutputType
import io.gitlab.jfronny.sdom.util.println
import kotlinx.coroutines.flow.MutableSharedFlow
import java.awt.BorderLayout
import javax.swing.JComponent

View File

@ -1,4 +1,4 @@
package io.gitlab.jfronny.sdom.ui
package io.gitlab.jfronny.sdom.util
import com.intellij.execution.ui.ConsoleView
import com.intellij.execution.ui.ConsoleViewContentType

View File

@ -0,0 +1,51 @@
package io.gitlab.jfronny.sdom.util
import java.awt.Desktop
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.regex.Pattern
import java.util.stream.Stream
object OSUtils {
var TYPE: Type? = System.getProperty("os.name", "generic").lowercase().let { os ->
if ((os.contains("mac")) || (os.contains("darwin"))) {
Type.MAC_OS
} else if (os.contains("win")) {
Type.WINDOWS
} else if (os.contains("nux")) {
Type.LINUX
} else {
null // probably fine :)
}
}
fun executablePathContains(executableName: String): Boolean {
return try {
Stream.of(
*System.getenv("PATH").split(Pattern.quote(File.pathSeparator).toRegex()).dropLastWhile { it.isEmpty() }
.toTypedArray())
.map { path: String -> path.replace("\"", "") }
.map { first: String -> Paths.get(first) }
.anyMatch { path: Path ->
(Files.exists(path.resolve(executableName))
&& Files.isExecutable(path.resolve(executableName)))
}
} catch (e: Exception) {
false
}
}
fun openFile(path: File) {
if (OSUtils.TYPE === OSUtils.Type.LINUX && OSUtils.executablePathContains("xdg-open")) {
Runtime.getRuntime().exec(arrayOf("xdg-open", path.absolutePath))
} else if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().open(path)
}
}
enum class Type {
WINDOWS, MAC_OS, LINUX
}
}

View File

@ -0,0 +1,81 @@
package io.gitlab.jfronny.sdom.util
import com.intellij.ui.jcef.JBCefScrollbarsHelper
import org.intellij.lang.annotations.Language
import java.util.Base64
@Language("HTML")
fun pdfBootstrap(pdf: ByteArray) = """
<!DOCTYPE html>
<html>
<body>
<script src="https://mozilla.github.io/pdf.js/build/pdf.mjs" type="module"></script>
<link rel='stylesheet' type='text/css' href='https://mozilla.github.io/pdf.js/web/viewer.css'>
<style type='text/css'>
${JBCefScrollbarsHelper.getOverlayScrollbarStyle()}
</style>
<script type="module">
const pdfData = atob('${Base64.getEncoder().encodeToString(pdf)}');
// Loaded via <script> tag, create shortcut to access PDF.js exports.
const { pdfjsLib } = globalThis;
// The workerSrc property shall be specified.
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://mozilla.github.io/pdf.js/build/pdf.worker.mjs';
// Using DocumentInitParameters object to load binary data.
const loadingTask = pdfjsLib.getDocument({data: pdfData});
loadingTask.promise.then(function(pdf) {
console.log('PDF loaded');
const body = document.getElementById('our-body');
for (let i = 1; i <= pdf.numPages; i++) {
pdf.getPage(i).then(function(page) {
console.log('Page loaded');
const scale = 1.5;
const viewport = page.getViewport({scale: scale});
// Prepare canvas using PDF page dimensions
const canvas = document.createElement('canvas');
const textLayer = document.createElement('div');
textLayer.className = "textLayer"
body.appendChild(canvas)
body.appendChild(textLayer)
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
// Render PDF page into canvas context
const renderContext = {
canvasContext: context,
viewport: viewport
};
const renderTask = page.render(renderContext);
renderTask.promise.then(function () {
return page.getTextContent()
}).then(function (textContent) {
pdfjsLib.renderTextLayer({
textContentSource: textContent,
container: textLayer,
viewport: viewport,
textDivs: []
});
textLayer.style.left = canvas.offsetLeft + 'px';
textLayer.style.top = canvas.offsetTop + 'px';
textLayer.style.height = canvas.offsetHeight + 'px';
textLayer.style.width = canvas.offsetWidth + 'px';
});
});
}
}, function (reason) {
// PDF loading error
console.error(reason);
});
</script>
<div id="our-body" style="width: 100%" />
</body>
</html>
""".trimIndent()

View File

@ -25,6 +25,7 @@
<!-- Extension points defined by the plugin.
Read more: https://plugins.jetbrains.com/docs/intellij/plugin-extension-points.html -->
<extensions defaultExtensionNs="com.intellij">
<fileEditorProvider implementation="io.gitlab.jfronny.sdom.ui.SDBrowserViewerProvider"/>
<toolWindow factoryClass="io.gitlab.jfronny.sdom.toolwindow.SDToolWindowFactory"
id="S-DOM" anchor="bottom" canCloseContents="true" icon="io.gitlab.jfronny.sdom.icons.SDIcons.ToolWindow" />
<notificationGroup id="sdom.notifications" displayType="BALLOON" />
@ -58,6 +59,12 @@
text="Refresh Problems">
<add-to-group group-id="io.gitlab.jfronny.sdom.actions.SDToolbarActions"/>
</action>
<action id="io.gitlab.jfronny.sdom.actions.SDStatementAction"
class="io.gitlab.jfronny.sdom.actions.SDStatementAction"
icon="com.intellij.icons.ExpUiIcons.Toolwindow.Documentation"
text="Open Statement">
<add-to-group group-id="io.gitlab.jfronny.sdom.actions.SDToolbarActions"/>
</action>
<action id="io.gitlab.jfronny.sdom.actions.SDSubmitAction"
class="io.gitlab.jfronny.sdom.actions.SDSubmitAction"
icon="com.intellij.icons.AllIcons.Actions.Upload"