[education/rkward] /: Add .rk.i18n() and message extraction for rkward R package

Thomas Friedrichsmeier null at kde.org
Thu Oct 16 20:57:56 BST 2025


Git commit e42b9aed2e5878c85398560d234741d79370d83e by Thomas Friedrichsmeier.
Committed on 14/10/2025 at 16:13.
Pushed by tfry into branch 'master'.

Add .rk.i18n() and message extraction for rkward R package

This also prompted a small update to .rk.cat.output(), to auto-paste
several arguments, obviating the need for extra paste() calls

M  +3    -3    Messages.sh
M  +8    -1    rkward/rbackend/rkrbackend.cpp
M  +1    -1    rkward/rbackend/rpackages/rkward/DESCRIPTION
M  +26   -4    rkward/rbackend/rpackages/rkward/R/internal.R
M  +2    -2    rkward/rbackend/rpackages/rkward/R/internal_graphics.R
M  +7    -11   rkward/rbackend/rpackages/rkward/R/public_graphics.R
M  +10   -10   rkward/rbackend/rpackages/rkward/R/rk.download_appimage.R
M  +14   -14   rkward/rbackend/rpackages/rkward/R/rk.filename-functions.R
M  +4    -4    rkward/rbackend/rpackages/rkward/R/rk.menu.R
M  +2    -2    rkward/rbackend/rpackages/rkward/R/rk.output.R
M  +4    -4    rkward/rbackend/rpackages/rkward/R/rk.print-functions.R
A  +198  -0    scripts/extract_r_message.py

https://invent.kde.org/education/rkward/-/commit/e42b9aed2e5878c85398560d234741d79370d83e

diff --git a/Messages.sh b/Messages.sh
index c3ee467cd..84aa07b47 100755
--- a/Messages.sh
+++ b/Messages.sh
@@ -8,9 +8,9 @@
 # invoke the extractrc script on all .ui, .rc, and .kcfg files in the sources
 # the results are stored in a pseudo .cpp file to be picked up by xgettext.
 $EXTRACTRC `find rkward -name \*.rc -a \! -name rkward_windows_icon.rc -o -name \*.ui -o -name \*.kcfg`  >> rc.cpp
-#
-# call xgettext on all source files. If your sources have other filename
-# extensions besides .cc, .cpp, and .h, just add them in the find call.
+# merge messages from .R files into rc.cpp
+python3 scripts/update_plugin_messages.py rkward/rbackend/rpackages/rkward/R/*.R >> rc.cpp
+# call xgettext on all source files in the main app.
 $XGETTEXT `find rkward -name \*.cpp -o -name \*.h -name \*.c` rc.cpp -o $podir/rkward.pot
 
 # extract messages from global .rkh pages: >> rkward__pages.pot
diff --git a/rkward/rbackend/rkrbackend.cpp b/rkward/rbackend/rkrbackend.cpp
index fa0541412..d77b19369 100644
--- a/rkward/rbackend/rkrbackend.cpp
+++ b/rkward/rbackend/rkrbackend.cpp
@@ -892,7 +892,13 @@ SEXP doSimpleBackendCall(SEXP _call) {
 	QStringList list = RKRSupport::SEXPToStringList(_call);
 	QString call = list[0];
 
-	if (call == QStringLiteral("unused.filename")) {
+	if (call == QStringLiteral("i18n")) {
+		auto msg = ki18n(list.value(1).toUtf8().constData());
+		for (int i = 2; i < list.length(); ++i) {
+			msg = msg.subs(list[i]);
+		}
+		return RKRSupport::StringListToSEXP(QStringList(msg.toString())); // TODO: Avoid wrapping into QSringList
+	} else if (call == QStringLiteral("unused.filename")) {
 		QString prefix = list.value(1);
 		QString extension = list.value(2);
 		QString dirs = list.value(3);
@@ -933,6 +939,7 @@ SEXP doUpdateLocale() {
 	RK_TRACE(RBACKEND);
 
 	RK_DEBUG(RBACKEND, DL_WARNING, "Changing locale");
+	// TODO: properly handle re-initialization of translation, too!
 	RKTextCodec::reinit();
 
 	return ROb(R_NilValue);
diff --git a/rkward/rbackend/rpackages/rkward/DESCRIPTION b/rkward/rbackend/rpackages/rkward/DESCRIPTION
index 9ba922832..2e3464a83 100755
--- a/rkward/rbackend/rpackages/rkward/DESCRIPTION
+++ b/rkward/rbackend/rpackages/rkward/DESCRIPTION
@@ -15,7 +15,7 @@ LazyLoad: yes
 Authors at R: c(person(given="Thomas", family="Friedrichsmeier", email="thomas.friedrichsmeier at kdemail.net", role=c("aut")), person(given="the RKWard", family="team",
                     email="rkward at kde.org", role=c("cre","aut")))
 Version: 0.8.3
-Date: 2025-10-10
+Date: 2025-10-11
 RoxygenNote: 7.3.3
 Collate: 
     'base_overrides.R'
diff --git a/rkward/rbackend/rpackages/rkward/R/internal.R b/rkward/rbackend/rpackages/rkward/R/internal.R
index 301ed195e..434eacb0a 100755
--- a/rkward/rbackend/rpackages/rkward/R/internal.R
+++ b/rkward/rbackend/rpackages/rkward/R/internal.R
@@ -274,14 +274,14 @@
 
 # hidden, as this is not portable to different output formats
 #' @export
-".rk.cat.output" <- function (x) {
-	cat (x, file = rk.get.output.html.file(), append = TRUE)
+".rk.cat.output" <- function(...) {
+	cat(..., file = rk.get.output.html.file(), append = TRUE)
 }
 
 #' @importFrom utils URLencode
 #' @export
-".rk.rerun.plugin.link" <- function (plugin, settings, label) {
-	.rk.cat.output (paste ("<a href=\"rkward://runplugin/", plugin, "/", URLencode (settings), "\">", label, "</a>", sep=""))
+".rk.rerun.plugin.link" <- function(plugin, settings, label) {
+	.rk.cat.output("<a href=\"rkward://runplugin/", plugin, "/", URLencode(settings), "\">", label, "</a>", sep="")
 }
 
 #' @export
@@ -360,3 +360,25 @@ assign(".rk.shadow.envs", new.env(parent=emptyenv()), envir=.rk.variables)
 	names(ret) <- c("added", "removed", "changed")
 	ret
 }
+
+# Not exported
+# So why to we use our own i18n mechanism, rather than R's gettext() and friends?
+# For one thing this is easier to integrate into the KDE translation workflow:
+#   - we need to extract i18n-calls and provide them to translators. Using R's update_pkg_po would mean to push R as a requirement
+#     to the extraction scripts. We'd like to avoid that, meaning, we need a custom extraction script, anyway.
+#   - the resulting .pot would either need to be provided separately, or merged with another, and the final translations would have to be
+#     moved to the places where R expects them. That means additional scripting complexity that we'd like to avoid
+#   - R's translation mechanism induces string puzzles, as it does not support the nifty arg replacement in KDE i18n.
+#     While we could trivially implement that on top of R's gettext(), that already implies using a custom wrapper.
+#   - This i18n-mechanism concerns only rkward package code, and nothing else. Not relevant to users.
+".rk.i18n" <- function(x, ...) {
+	args <- as.character(...)
+	if (!.rk.inside.rkward.session()) {
+		if (length(args)) {
+			for (i in 1:length(args)) x <- sub(x, paste0("%", i), args[i], fixed=TRUE)
+		}
+		x
+	} else {
+		.rk.call.backend("i18n", c(x, args))
+	}
+}
diff --git a/rkward/rbackend/rpackages/rkward/R/internal_graphics.R b/rkward/rbackend/rpackages/rkward/R/internal_graphics.R
index f69e9a68a..ac708621b 100644
--- a/rkward/rbackend/rpackages/rkward/R/internal_graphics.R
+++ b/rkward/rbackend/rpackages/rkward/R/internal_graphics.R
@@ -15,8 +15,8 @@
 #' @importFrom grDevices dev.new
 #' @export
 "rk.screen.device" <- function (...) {
-	warning ("rk.screen.device() is obsolete.\nUse one of dev.new(), RK(), or rk.embed.device(), instead.")
-	dev.new (...)
+	warning(.rk.i18n("rk.screen.device() is obsolete.\nUse one of dev.new(), RK(), or rk.embed.device(), instead."))
+	dev.new(...)
 }
 
 # Fetch the current size of the given RK() device from the frontend, and redraw
diff --git a/rkward/rbackend/rpackages/rkward/R/public_graphics.R b/rkward/rbackend/rpackages/rkward/R/public_graphics.R
index 8f79dab12..55d657f38 100644
--- a/rkward/rbackend/rpackages/rkward/R/public_graphics.R
+++ b/rkward/rbackend/rpackages/rkward/R/public_graphics.R
@@ -69,17 +69,14 @@
 	if (device.type == "PNG") {
 		filename <- rk.get.tempfile.name(prefix = "graph", extension = ".png")
 		ret <- png(filename = file.path(filename), width = width, height = height, ...)
-		.rk.cat.output(paste("<img src=\"", make.url (names (filename)), "\" width=\"", width,
-			"\" height=\"", height, "\"><br>", sep = ""))
+		.rk.cat.output("<img src=\"", make.url(names(filename)), "\" width=\"", width, "\" height=\"", height, "\"><br>", sep = "")
 	} else if (device.type == "JPG") {
 		if (missing (quality)) {
-			quality = getOption ("rk.graphics.jpg.quality")		# COMPAT: getOption (x, *default*) not yet available in R 2.9
-			if (is.null (quality)) quality = 75
+			quality = getOption("rk.graphics.jpg.quality", 75)
 		}
 		filename <- rk.get.tempfile.name(prefix = "graph", extension = ".jpg")
 		ret <- jpeg(filename = file.path(filename), width = width, height = height, "quality"=quality, ...)
-		.rk.cat.output(paste("<img src=\"", make.url (names (filename)), "\" width=\"", width,
-			"\" height=\"", height, "\"><br>", sep = ""))
+		.rk.cat.output("<img src=\"", make.url(names(filename)), "\" width=\"", width, "\" height=\"", height, "\"><br>", sep = "")
 	} else if (device.type == "SVG") {
 		if (!capabilities ("cairo")) {	# cairo support is not always compiled in
 			requireNamespace ("Cairo")
@@ -89,13 +86,12 @@
 		# ret <- svg(filename = file.path(filename), ...)
 		# CairoSVG() uses "file" as first argument, grDevices uses "filename" in svg()
 		ret <- svg(file.path(filename), ...)
-		.rk.cat.output(paste("<object data=\"", make.url (names (filename)), "\" type=\"image/svg+xml\" width=\"", width,
-			"\" height=\"", height, "\">\n", sep = ""))
-		.rk.cat.output(paste("<param name=\"src\" value=\"", make.url (names (filename)), "\">\n", sep = ""))
-		.rk.cat.output(paste("This browser appears incapable of displaying SVG object. The SVG source is at:", filename))
+		.rk.cat.output("<object data=\"", make.url(names(filename)), "\" type=\"image/svg+xml\" width=\"", width,
+			"\" height=\"", height, "\">\n", "<param name=\"src\" value=\"", make.url(names(filename)), "\">\n", sep = "")
+		.rk.cat.output("This browser appears incapable of displaying SVG object. The SVG source is at:", filename)
 		.rk.cat.output("</object>")
 	} else {
-		stop (paste ("Device type \"", device.type, "\" is unknown to RKWard", sep=""))
+		stop(.rk.i18n("Device type \"%1\" is unknown to RKWard", device.type))
 	}
 
 	invisible (ret)
diff --git a/rkward/rbackend/rpackages/rkward/R/rk.download_appimage.R b/rkward/rbackend/rpackages/rkward/R/rk.download_appimage.R
index 2fbc56a9d..939eb150a 100644
--- a/rkward/rbackend/rpackages/rkward/R/rk.download_appimage.R
+++ b/rkward/rbackend/rpackages/rkward/R/rk.download_appimage.R
@@ -64,7 +64,7 @@ rk.download_appimage <- function(
   rkward::require(XiMpLe)
   XiMpLe_version <- utils::packageVersion("XiMpLe")
   if(isFALSE(XiMpLe_version >= "0.11.3")){
-    stop(simpleError(paste0("rk.download_appimage() requires XiMpLe >= 0.11.3, but only found ", XiMpLe_version, ". Please update XiMpLe.")))
+    stop(.rk.i18n("rk.download_appimage() requires XiMpLe >= 0.11.3, but only found %1. Please update XiMpLe.", XiMpLe_version), call.=FALSE)
   } else {}
   rk_ai_html <- XiMpLe::parseXMLTree(url, drop="empty_attributes")
   rk_ai_hrefs <- XiMpLe::XMLScanDeep(rk_ai_html, find="href")
@@ -74,17 +74,17 @@ rk.download_appimage <- function(
 
   if(isTRUE(download)){
     if(!dir.exists(dir)){
-      stop(simpleError(paste0("Target directory does not exist:\n  ", dir)))
+      stop(.rk.i18n("Target directory '%1' does not exist\n", dir), call.=FALSE)
     } else {}
     have_backup <- FALSE
     target_file <- file.path(dir, filename)
     if(file.exists(target_file)){
       if(isFALSE(overwrite)){
-        stop(simpleError(paste0("Target file already exists, use the \"overwrite\" argument to replace it:\n  ", target_file)))
+        stop(.rk.i18n("Target file '%1' already exists, use the \"overwrite\" argument to replace it:\n", target_file), call.=FALSE)
       } else {}
       # create backup of current AppImage as a fallback
       rk_ai_backup <- paste0(target_file, ".backup_", format(Sys.time(), "%Y-%m-%d_%H%M%S"))
-      message(paste0("Creating backup of current AppImage:\n  ", rk_ai_backup))
+      message(.rk.i18n("Creating backup of current AppImage: %1\n  ", rk_ai_backup))
       file.rename(
           from=target_file
         , to=rk_ai_backup
@@ -109,35 +109,35 @@ rk.download_appimage <- function(
                 from=rk_ai_backup
               , to=target_file
             )
-            stop("Something went wrong with the download! Restored previous AppImage from backup.", call.=FALSE)
+            stop(.rk.i18n("Something went wrong with the download! Restored previous AppImage from backup."), call.=FALSE)
           } else {
             file.remove(target_file)
-            stop("Something went wrong with the download!", call.=FALSE)
+            stop(.rk.i18n("Something went wrong with the download!"), call.=FALSE)
           }
         }
       , finally = options(timeout = timeout_orig)
     )
     if(isTRUE(dl_status == 0)){
-      message("Download successful, setting file permissions.")
+      message(.rk.i18n("Download successful, setting file permissions."))
       Sys.chmod(
           paths=target_file
         , mode="0755"
         , use_umask=TRUE
       )
       if(isTRUE(have_backup)){
-        message("Removing backup of previous AppImage.")
+        message(.rk.i18n("Removing backup of previous AppImage."))
         file.remove(rk_ai_backup)
       } else {}
       return(invisible(target_file))
     } else {
       if(isTRUE(have_backup)){
-        warning("Something went wrong with the download! Restoring previous AppImage from backup.", call.=FALSE)
+        warning(.rk.i18n("Something went wrong with the download! Restoring previous AppImage from backup."), call.=FALSE)
         file.rename(
             from=rk_ai_backup
           , to=target_file
         )
       } else {
-        warning("Something went wrong with the download!", call.=FALSE)
+        warning(.rk.i18n("Something went wrong with the download!"), call.=FALSE)
         file.remove(target_file)
       }
     }
diff --git a/rkward/rbackend/rpackages/rkward/R/rk.filename-functions.R b/rkward/rbackend/rpackages/rkward/R/rk.filename-functions.R
index e15b8283c..ecfc3be23 100644
--- a/rkward/rbackend/rpackages/rkward/R/rk.filename-functions.R
+++ b/rkward/rbackend/rpackages/rkward/R/rk.filename-functions.R
@@ -137,19 +137,19 @@
 			if(!is.null(li$codepage)) return(paste0("windows-", li$codepage))
 			return(tail(strsplit(Sys.getlocale("LC_CTYPE"), ".") ,1))
 		}
-		.rk.cat.output (paste ("<?xml version=\"1.0\" encoding=\"", encoding.name(), "\"?>\n", sep=""))
-		.rk.cat.output ("<html><head>\n<title>RKWard Output</title>\n")
+		.rk.cat.output("<?xml version=\"1.0\" encoding=\"", encoding.name(), "\"?>\n", sep="")
+		.rk.cat.output("<html><head>\n<title>RKWard Output</title>\n")
 		if (!is.null (css)) {
 			cssfilename <- paste (sub ("\\.[^.]*$", "", basename (x)), ".css", sep="")
-			.rk.cat.output (paste ("<link rel=\"StyleSheet\" type=\"text/css\" href=\"", cssfilename, "\"/>\n", sep=""))
+			.rk.cat.output("<link rel=\"StyleSheet\" type=\"text/css\" href=\"", cssfilename, "\"/>\n", sep="")
 			cssfile <- file.path (dirname (x), cssfilename)
 			if (!file.copy (css, cssfile, overwrite=TRUE)) {
-				warning ("Failed to copy CSS file ", css, " to ", cssfile)
+				warning(.rk.i18n("Failed to copy CSS file '%1' to '%2'", css, cssfile))
 			}
 		}
 		# the next part defines a JavaScript function to add individual results to a global table of contents menu in the document
 		if (style != "preview") {
-			.rk.cat.output (paste ("\t<script type=\"text/javascript\">
+			.rk.cat.output("\t<script type=\"text/javascript\">
 			<!--
 				function addToTOC(id, level){
 					var fullHeader = document.getElementById(id);
@@ -197,18 +197,18 @@
 						}
 					}
 				}
-			// -->\n\t</script>\n", sep=""))
+			// -->\n\t</script>\n", sep="")
 			# positioning of the TOC is done by CSS, default state is hidden
 			# see $SRC/rkward/pages/rkward_output.css
 		}
 
-		if (!is.null (additional.header.contents)) .rk.cat.output (as.character (additional.header.contents))
-		.rk.cat.output ("</head>\n<body>\n")
+		if (!is.null (additional.header.contents)) .rk.cat.output(as.character(additional.header.contents))
+		.rk.cat.output("</head>\n<body>\n")
 		if (style != "preview") {
 			# This initial output mostly to indicate the output is really there, just empty for now
-			.rk.cat.output (paste ("<a name=\"top\"></a>\n<pre>RKWard output initialized on", .rk.date (), "</pre>\n"))
+			.rk.cat.output("<a name=\"top\"></a>\n<pre>RKWard output initialized on", .rk.date (), "</pre>\n")
 			# an empty <div> where the TOC menu gets added to dynamically, and a second one to toggle show/hide
-			.rk.cat.output (paste (
+			.rk.cat.output(
 				"<div id=\"RKWardResultsTOCShown\" class=\"RKTOC\">\n",
 				"\t<a onclick=\"javascript:switchVisible('RKWardResultsTOCHidden','RKWardResultsTOCShown'); return false;\" href=\"\" class=\"toggleTOC\">Hide TOC</a>\n",
 				"\t<span class=\"right\"><a href=\"#top\" class=\"toggleTOC\">Go to top</a></span>\n<br />",
@@ -220,7 +220,7 @@
 				"<div id=\"RKWardResultsTOCHidden\" class=\"RKTOC RKTOChidden\">\n",
 				"\t<a onclick=\"javascript:switchVisible('RKWardResultsTOCShown','RKWardResultsTOCHidden'); return false;\" href=\"\" class=\"toggleTOC\">Show TOC</a>\n",
 				"\t<span class=\"right\"><a href=\"#top\" class=\"toggleTOC\">Go to top</a></span>\n",
-				"</div>\n", sep=""))
+				"</div>\n", sep="")
 		}
 	}
 
@@ -374,11 +374,11 @@ function registerPlot(element) {
 
 	hook <- RK.addHook(
 		after.create=function(devnum, ...) {
-			.rk.cat.output("<div align=\"right\">Plot window created</div>\n");
+			.rk.cat.output("<div align=\"right\">", .rk.i18n("Plot window created"), "</div>\n");
 			devs[[as.character(devnum)]] <<- RK.revision(devnum)
 		},
 		in.close=function(devnum, ...) {
-			.rk.cat.output("<div align=\"right\">Plot window closed</div>\n");
+			.rk.cat.output("<div align=\"right\">", .rk.i18n("Plot window closed"), "</div>\n");
 			devs[[as.character(devnum)]] <<- NULL
 		}
 	)
@@ -388,7 +388,7 @@ function registerPlot(element) {
 			currev <- RK.revision(as.numeric(devnum))
 			if (devs[[devnum]] < currev) {
 				cur <- dev.cur()
-				.rk.cat.output("<div align=\"right\"><details><script>registerPlot(document.currentScript.parentElement);</script><summary>Plot updated (click to show)</summary><p>\n");
+				.rk.cat.output("<div align=\"right\"><details><script>registerPlot(document.currentScript.parentElement);</script><summary>", .rk.i18n("Plot updated (click to show)"), "</summary><p>\n");
 				#rk.graph.on(width=200, height=200, pointsize=6)
 				rk.graph.on()
 				out <- dev.cur()
diff --git a/rkward/rbackend/rpackages/rkward/R/rk.menu.R b/rkward/rbackend/rpackages/rkward/R/rk.menu.R
index 57c709efb..e3fe4cf41 100644
--- a/rkward/rbackend/rpackages/rkward/R/rk.menu.R
+++ b/rkward/rbackend/rpackages/rkward/R/rk.menu.R
@@ -79,7 +79,7 @@ rk.menu <- setRefClass("rk.menu",
 		x <- .retrieve(TRUE)
 		x$label <- label
 		if (!missing(func)) {
-			if (exists("children", envir=x)) stop(paste("Submenu", paste(path, collapse="/"), "cannot be redefined as a leaf item."))
+			if (exists("children", envir=x)) stop(.rk.i18n("Submenu %1 cannot be redefined as a leaf item.", paste(path, collapse="/")))
 			x$fun <- func
 		}
 		.rk.call.async("menuupdate", rk.menu()$.list())
@@ -111,12 +111,12 @@ rk.menu <- setRefClass("rk.menu",
 		for(p in path) {
 			walkedpath <- c(walkedpath, p)
 			if (!exists("children", envir=parent)) {
-				if (!create) stop(paste("Menu path", paste(walkedpath, collapse="/"), "is not defined"))
-				if (exists("fun", envir=parent)) stop(paste("Leaf menu item", paste(walkedpath, collapse="/"), "cannot be redefined as a submenu."))
+				if (!create) stop(.rk.i18n("Menu path %1 is not defined", paste(walkedpath, collapse="/")))
+				if (exists("fun", envir=parent)) stop(.rk.i18n("Leaf menu item %1 cannot be redefined as a submenu.", paste(walkedpath, collapse="/")))
 				parent$children <- list()
 			}
 			if (is.null(parent$children[[p]])) {
-				if (!create) stop(paste("Menu path", paste(walkedpath, collapse="/"), "is not defined"))
+				if (!create) stop(.rk.i18n("Menu path %1 is not defined", paste(walkedpath, collapse="/")))
 				parent$children[[p]] <- new.env()
 			}
 			parent = parent$children[[p]]
diff --git a/rkward/rbackend/rpackages/rkward/R/rk.output.R b/rkward/rbackend/rpackages/rkward/R/rk.output.R
index 3ef6c683b..f1e5769bd 100644
--- a/rkward/rbackend/rpackages/rkward/R/rk.output.R
+++ b/rkward/rbackend/rpackages/rkward/R/rk.output.R
@@ -84,7 +84,7 @@ RK.Output <- setRefClass(Class="RK.Output", fields=list(id="character"),
 		},
 		export=function(filename, overwrite=NULL) {
 "Save this output, to the specified location, but keep it associated with the previous location (\"save a copy\")."
-			if (missing(filename)) stop("No file name specified")
+			if (missing(filename)) stop(.rk.i18n("No file name specified"))
 			.rk.call.nested("output", c ("export", .checkId(), if(is.null(overwrite)) "ask" else if(isTRUE(overwrite)) "force" else "fail", filename))
 		},
 		clear=function(discard=NULL) {
@@ -113,7 +113,7 @@ Do not write anything to the target filename, directly! This is purely for infor
 		.checkId=function() {
 "For internal use: Throws an error, if the id parameter is NULL or too long, returns a length one character vector otherwise."
 			i <- as.character(id)
-			if (length(i) != 1) stop ("Invalid output id. Use rk.output() to obtain a valid output handle.")
+			if (length(i) != 1) stop(.rk.i18n("Invalid output id. Use rk.output() to obtain a valid output handle."))
 			i
 		}
 	))
diff --git a/rkward/rbackend/rpackages/rkward/R/rk.print-functions.R b/rkward/rbackend/rpackages/rkward/R/rk.print-functions.R
index b2643137b..419df501b 100644
--- a/rkward/rbackend/rpackages/rkward/R/rk.print-functions.R
+++ b/rkward/rbackend/rpackages/rkward/R/rk.print-functions.R
@@ -104,7 +104,7 @@
 		filename <- rk.get.tempfile.name (name, ".html")
 		dir <- rk.get.tempfile.name (name, "_data")
 		htmlwidgets::saveWidget (x, filename, selfcontained=FALSE, libdir=dir)
-		.rk.cat.output (paste0 ("<object width=\"100%\" height=\"100%\" data=\"file://", filename, "\" onload=\"this.style.height = this.contentWindow.document.body.scrollHeight + 'px';\"></object>"))
+		.rk.cat.output("<object width=\"100%\" height=\"100%\" data=\"file://", filename, "\" onload=\"this.style.height = this.contentWindow.document.body.scrollHeight + 'px';\"></object>", sep="")
 	} else if (inherits (x, "gvis")) {
 		requireNamespace ("googleVis", quietly = TRUE)
 		print (x, file=rk.get.output.html.file(), append=TRUE)
@@ -113,7 +113,7 @@
 		if(requireNamespace ("R2HTML", quietly = TRUE)) {
 			R2HTML::HTML(x, file=htmlfile, ...)
 		} else {
-			.rk.cat.output("Please install package R2HTML to enable output!")
+			.rk.cat.output(.rk.i18n("Please install package R2HTML to enable output!"))
 		}
 	}
 }
@@ -150,7 +150,7 @@
 	if (length (parameters)) {
 		# legacy handling: parameter=value used to be passed as parameter, value
 		if (is.null (names (parameters))) {
-			warning ("Unnamed parameter lists are deprecated in rk.header()")
+			warning(.rk.i18n("Unnamed parameter lists are deprecated in rk.header()"))
 			s <- seq.int (1, length (parameters), by=2)
 			pnames <- as.character (parameters[s])
 			parameters <- parameters[s+1]
@@ -243,7 +243,7 @@
 		cat (x)
 		cat ("</h3>")
 	} else {
-		stop ("uninmplemented")
+		stop(.rk.i18n("Printing object of class '%1' is not inmplemented", class(x)))
 	}
 }
 
diff --git a/scripts/extract_r_message.py b/scripts/extract_r_message.py
new file mode 100755
index 000000000..3bc399b28
--- /dev/null
+++ b/scripts/extract_r_message.py
@@ -0,0 +1,198 @@
+#! /usr/bin/env python3
+# This file is part of the RKWard project (https://rkward.kde.org).
+# SPDX-FileCopyrightText: 2025 by Thomas Friedrichsmeier <thomas.friedrichsmeier at kdemail.net>
+# SPDX-FileContributor: The RKWard Team <rkward at kde.org>
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# Extracts messages form R packages. (Of course R has its own update_pkg_po(), but
+# we don't want to asssume an R installation for the extraction job.
+
+import os
+import codecs
+import sys
+import subprocess
+from xml.dom import minidom
+import html.parser
+import copy
+import re
+
+def usage ():
+  print("Usage: " + sys.argv[0] + " files")
+  exit (1)
+
+# initialize globals, and parse args
+sources = []
+outfile = sys.stdout
+for arg in (list (sys.argv[1:])):
+  if (arg.startswith ("--")):
+    usage ()
+  else:
+    sources.append (arg)
+if (len (sources) < 1):
+  usage ()
+
+def normalize (text):
+  lines = text.split ("\n")
+  nlines = []
+  for line in lines:
+    nlines.append (' '.join (line.strip ().split ()))	# remove whitespace at start, end, and simplify whitespace within each line
+  return ' '.join (nlines)
+
+def writeouti18n (call):
+  if (call.find ("%") >= 0):
+    outfile.write ("/* xgettext:no-c-format */ ")
+  outfile.write (call + "\n")
+
+# basic R parsing (comments / strings)
+class RParseBuffer:
+  def __init__ (self, content):
+    self.buf = content
+    self.comment = ""
+    self.nline = 0
+    self.nchar = 0
+  def atEof (self):
+    return (self.nchar >= len (self.buf))
+  def currentChar (self):
+    if (self.atEof ()):
+      return ""
+    return (self.buf[self.nchar])
+  def advance (self, n=1):
+    for step in range (n):
+      self.nchar += 1
+      if (self.nchar >= (len (self.buf) - 1)):
+        return False
+      if (self.buf[self.nchar] == "\n"):
+        self.nline += 1
+    return True
+  def skipWhitespace (self):
+    while (self.buf[self.nchar].isspace ()):
+      if (not self.advance ()):
+        break
+  def seekUntil (self, needle):
+    fromchar = self.nchar
+    while (not self.startswith (needle)):
+      if (not self.advance ()):
+        break
+    return self.buf[fromchar:self.nchar]
+  # returns True, if the given word is found at the current position, _and_ it this is a full keyword (i.e. preceded and followed by some delimiter), and could be a function call
+  def isAtFunctionCall (self, word):
+    if (not self.startswith (word)):
+      return False
+    # return False if previous char is no delimiter
+    if (self.nchar < 0 and self.buf[self.nchar-1].isalnum ()):
+      return False
+    # return False if next char is no delimiter
+    nextchar = self.buf[self.nchar + len (word)]
+    if (not (nextchar.isspace () or nextchar == '(')):
+      return False
+    return True
+  def seek_line_comment_end (self):
+    comment = ""
+    while True:
+      comment += self.seekUntil ("\n")
+      self.skipWhitespace ()    
+      if (self.startswith ("#")):
+        self.advance (1)
+      else:
+        break
+    return comment
+  def nibbleCallParameters (self):
+    fromchar = self.nchar
+    self.nibble_until ('(')
+    self.advance ()
+    self.nibble_until (')', True)
+    self.advance ()
+    return self.buf[fromchar:self.nchar]
+
+  def nibble_until (self, string, skip_over_parentheses=False):
+    fromchar = self.nchar
+    while (not self.startswith (string)):
+      if (self.atEof ()):
+        break
+      if (self.buf[self.nchar] in ['"', '\'', '`']):
+        ochar = self.buf[self.nchar]
+        while (self.advance ()):
+          if (self.startswith ('\\')):
+            self.advance ()	# skip next char
+          elif (self.startswith (ochar)):
+            break
+      elif (self.startswith ("#")):
+        self.comment = self.seek_line_comment_end ()
+      elif (skip_over_parentheses and (self.startswith ("("))):	# skip over nested parentheses
+        self.advance ()
+        self.nibble_until (")", True)
+      elif (not self.buf[self.nchar].isspace ()):
+        self.comment = ""
+      if (not self.advance ()):
+        break
+    return (self.buf[fromchar:self.nchar])
+  def startswith (self, string):
+    return self.buf.startswith (string, self.nchar)
+
+def handleRChunk (filename):
+  global outfile
+  keywords = ("gettext", "ngettext", "stop", "warning", "message", ".rk.i18n") # TODO change me!
+
+  # Convert single quoted and backtick quoted strings in the input chunk to double quotes (so xgettext can work in C-style).
+  def normalizeQuotes (chunk):
+    pos = 0
+    current_quote_symbol = ""
+    output = ""
+    strip_closing_parentheses = 0
+    while (pos < len (chunk)):
+      c = chunk[pos]
+      if c == "\\":
+        nc = chunk[pos+1]
+        if ((nc != current_quote_symbol) or (nc == '"')):
+          output += c
+        output += nc
+        pos += 1
+      elif c in ['"', '\'', '`']:
+        if (current_quote_symbol == ""):
+          current_quote_symbol = c
+          output += '"'
+        elif current_quote_symbol == c:
+          current_quote_symbol = ""
+          output += '"'
+        elif c == '"':
+          output += '\\\"'
+        else:
+          output += c
+      else:
+        output += c
+      pos += 1
+    return output
+
+  buf = ""
+  with codecs.open(filename, 'r', 'utf-8') as f:
+    buf = f.read()
+  rbuf = RParseBuffer (buf)
+  while (True):
+    call = ""
+    junk = rbuf.nibble_until (keywords)
+    if (rbuf.atEof ()):
+      break
+    for candidate in keywords:
+      if (rbuf.isAtFunctionCall (candidate)):
+        call = candidate
+        break
+    if (call == ""):
+      # skip over somethingelsei18nsomethingelse identifiers, i.e. those not matched, above
+      rbuf.advance ()
+      continue
+
+    rbuf.advance (len (call))
+    comment = normalize (rbuf.comment)
+    line = rbuf.nline
+    parameters = rbuf.nibbleCallParameters ()
+    text = "/* "
+    if (comment.lower ().startswith ("i18n:") or comment.lower ().startswith ("translators:")):
+      text += "i18n: " + comment + "\n"
+    text += "i18n: file: " + filename + ":" + str(line)
+    text += " */\n" + call + normalizeQuotes (parameters) + ";\n"
+    writeouti18n (text)
+
+#######
+# Loop over toplevel_sources (specified on command line.
+for source in sources:
+  handleRChunk(source)



More information about the rkward-tracker mailing list