Further work on animated GIF export: XDG Pictures
Using XDG's user dir settings to determine where pictures are stored for a user (e.g., "~/Pictures" -- used as a fallback). May be overridden using "--exportdir". Also, while I was updating some docs, replace references to "Mac OS X" with "macOS", the new name of that OS these days.
This commit is contained in:
parent
683bbf5f19
commit
f8cce36435
13 changed files with 329 additions and 68 deletions
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
get_fname.c
|
||||
|
||||
Copyright (c) 2009
|
||||
Copyright (c) 2009 - 2020
|
||||
http://www.tuxpaint.org/
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
|
|
@ -30,26 +30,39 @@
|
|||
#include "debug.h"
|
||||
#include "compiler.h"
|
||||
|
||||
/* DIR_SAVE: Where is the user's saved directory?
|
||||
This is where their saved files are stored
|
||||
and where the "current_id.txt" file is saved.
|
||||
/*
|
||||
See tuxpaint.c for the OS-specific defaults.
|
||||
|
||||
Windows predefines "savedir" as:
|
||||
"C:\Documents and Settings\%USERNAME%\Application Data\TuxPaint"
|
||||
though it may get overridden with "--savedir" option
|
||||
* DIR_SAVE: Where does the user's drawings get saved?
|
||||
|
||||
BeOS similarly predefines "savedir" as "./userdata"...
|
||||
This is where their saved files (PNG) are stored, and where the
|
||||
"current_id.txt" file is saved (so we can re-load the latest
|
||||
picture upon a subsequent launch). Generally, end users aren't
|
||||
expected to access the files in here directly, but they can.
|
||||
|
||||
Macintosh: It's under ~/Library/Application Support/TuxPaint
|
||||
The defaults may be overridden with the "--savedir" option.
|
||||
|
||||
Linux & Unix: It's under ~/.tuxpaint
|
||||
* DIR_DATA: Where is the user's data directory?
|
||||
|
||||
DIR_DATA: Where is the user's data directory?
|
||||
This is where local fonts, brushes and stamps can be found. */
|
||||
This is where local (user-specific) fonts, brushes, stamps,
|
||||
starter images, etc., can be found. End users only put things
|
||||
here if they wish to extend their Tux Paint experience.
|
||||
|
||||
The defaults may be overridden with the "--datadir" option.
|
||||
|
||||
* DIR_EXPORT: Where does Tux Paint export drawings / animations?
|
||||
|
||||
This is where single images, or animated GIF slideshows,
|
||||
will be exported. It is expected that this is an obvious,
|
||||
and easily-accessible place for end users to retrieve the exports.
|
||||
|
||||
The defaults may be overridden with the "--exportdir" option.
|
||||
*/
|
||||
|
||||
|
||||
const char *savedir;
|
||||
const char *datadir;
|
||||
const char *exportdir;
|
||||
|
||||
// FIXME: We shouldn't be allocating memory all the time.
|
||||
// There should be distinct functions for each directory.
|
||||
|
|
@ -62,16 +75,28 @@ const char *datadir;
|
|||
* (data file, or saved images?)
|
||||
*
|
||||
* @param name Filaneme
|
||||
* @param kind What kind of file? (DIR_SAVE or DIR_DATA?)
|
||||
* @param kind What kind of file? (DIR_SAVE, DIR_DATA, or DIR_EXPORT?)
|
||||
* @return Full fillpath
|
||||
*/
|
||||
char *get_fname(const char *const name, int kind)
|
||||
{
|
||||
char f[512];
|
||||
const char *restrict const dir = (kind == DIR_SAVE) ? savedir : datadir;
|
||||
// const char *restrict const dir;
|
||||
const char * dir;
|
||||
|
||||
// Some mkdir()'s don't like trailing slashes
|
||||
snprintf(f, sizeof(f), "%s%c%s", dir, (*name) ? '/' : '\0', name);
|
||||
if (kind == DIR_SAVE) {
|
||||
dir = savedir;
|
||||
} else if (kind == DIR_DATA) {
|
||||
dir = datadir;
|
||||
} else if (kind == DIR_EXPORT) {
|
||||
dir = exportdir;
|
||||
}
|
||||
|
||||
snprintf(f, sizeof(f),
|
||||
"%s%c%s",
|
||||
dir, (*name) ? '/' : '\0', /* Some mkdir()'s don't like trailing slashes */
|
||||
name);
|
||||
|
||||
return strdup(f);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
get_fname.h
|
||||
|
||||
Copyright (c) 2009
|
||||
Copyright (c) 2009 - July 25, 2020
|
||||
http://www.tuxpaint.org/
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
|
|
@ -27,11 +27,14 @@
|
|||
|
||||
extern const char *savedir;
|
||||
extern const char *datadir;
|
||||
extern const char *exportdir;
|
||||
|
||||
enum
|
||||
{
|
||||
/* (See get_fname.c for details) */
|
||||
DIR_SAVE,
|
||||
DIR_DATA
|
||||
DIR_DATA,
|
||||
DIR_EXPORT
|
||||
};
|
||||
|
||||
char *get_fname(const char *const name, int kind);
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ datadir, MULTI(datadir)
|
|||
disablescreensaver, POSBOOL(disable_screensaver)
|
||||
dontgrab, NEGBOOL(grab_input)
|
||||
dontmirrorstamps, NEGBOOL(mirrorstamps)
|
||||
exportdir, MULTI(exportdir)
|
||||
fancycursors, NEGBOOL(no_fancy_cursors)
|
||||
fullscreen, MULTI(parsertmp_fullscreen_native)
|
||||
grab, POSBOOL(grab_input)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ struct cfginfo
|
|||
const char *disable_stamp_controls;
|
||||
const char *dont_do_xor;
|
||||
const char *dont_load_stamps;
|
||||
const char *exportdir;
|
||||
const char *fullscreen;
|
||||
const char *grab_input;
|
||||
const char *hide_cursor;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
# Bill Kendrick <bill@newbreedsoftware.com>; http://www.tuxpaint.org/
|
||||
# Based on inkscape's completion file, by allali@univ-mlv.fr
|
||||
#
|
||||
# Last modified 2020-07-25
|
||||
#
|
||||
# $Id$
|
||||
|
||||
# FIXME: See http://www.debian-administration.org/articles/316 for an intro
|
||||
|
|
@ -57,7 +59,7 @@ _tuxpaint()
|
|||
--saveoverask --saveover --saveovernew \
|
||||
--nosave --save \
|
||||
--autosave --noautosave \
|
||||
--savedir --datadir \
|
||||
--savedir --datadir --exportdir \
|
||||
--printdelay= \
|
||||
--altprintmod --altprintalways --altprintnever \
|
||||
--papersize \
|
||||
|
|
|
|||
193
src/tuxpaint.c
193
src/tuxpaint.c
|
|
@ -22,7 +22,7 @@
|
|||
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
(See COPYING.txt)
|
||||
|
||||
June 14, 2002 - May 23, 2020
|
||||
June 14, 2002 - July 25, 2020
|
||||
*/
|
||||
|
||||
|
||||
|
|
@ -1317,6 +1317,8 @@ static void handle_motioners(int oldpos_x, int oldpos_y, int motioner, int hatmo
|
|||
|
||||
static void handle_joybuttonupdownscl(SDL_Event event, int oldpos_x, int oldpos_y, SDL_Rect real_r_tools);
|
||||
|
||||
char * get_xdg_user_dir(const char * dir_type, const char * fallback);
|
||||
|
||||
|
||||
/* Magic tools API and tool handles: */
|
||||
|
||||
|
|
@ -1977,9 +1979,14 @@ static int do_new_dialog_add_colors(SDL_Surface * *thumbs, int num_files, int *d
|
|||
char * *d_exts, int *white_in_palette);
|
||||
static int do_color_picker(void);
|
||||
static int do_color_sel(void);
|
||||
|
||||
static int do_slideshow(void);
|
||||
static void play_slideshow(int *selected, int num_selected, char *dirname, char **d_names, char **d_exts, int speed);
|
||||
static void draw_selection_digits(int right, int bottom, int n);
|
||||
|
||||
void do_export_gif(void);
|
||||
char * get_export_filepath(void);
|
||||
|
||||
static void wait_for_sfx(void);
|
||||
static void rgbtohsv(Uint8 r8, Uint8 g8, Uint8 b8, float *h, float *s, float *v);
|
||||
static void hsvtorgb(float h, float s, float v, Uint8 * r8, Uint8 * g8, Uint8 * b8);
|
||||
|
|
@ -6518,6 +6525,9 @@ void show_usage(int exitcode)
|
|||
" [--nolockfile]\n"
|
||||
" [--datadir DIRECTORY]\n"
|
||||
"\n"
|
||||
" Exporting:\n"
|
||||
" [--exportdir DIRECTORY]\n"
|
||||
"\n"
|
||||
" Accessibility:\n"
|
||||
" [--mouse-accessibility]\n"
|
||||
" [--mouse | --keyboard]\n"
|
||||
|
|
@ -11799,12 +11809,12 @@ static void load_current(void)
|
|||
* FIXME
|
||||
*/
|
||||
/* Make sure we have a 'path' directory */
|
||||
static int make_directory(const char *path, const char *errmsg)
|
||||
static int make_directory(int dir_type, const char *path, const char *errmsg)
|
||||
{
|
||||
char *fname;
|
||||
int res;
|
||||
|
||||
fname = get_fname(path, DIR_SAVE);
|
||||
fname = get_fname(path, dir_type);
|
||||
res = mkdir(fname, 0755);
|
||||
if (res != 0 && errno != EEXIST)
|
||||
{
|
||||
|
|
@ -11826,7 +11836,7 @@ static void save_current(void)
|
|||
char *fname;
|
||||
FILE *fi;
|
||||
|
||||
if (!make_directory("", "Can't create user data directory"))
|
||||
if (!make_directory(DIR_SAVE, "", "Can't create user data directory"))
|
||||
{
|
||||
draw_tux_text(TUX_OOPS, strerror(errno), 0);
|
||||
return;
|
||||
|
|
@ -13109,7 +13119,7 @@ static int do_save(int tool, int dont_show_success_results)
|
|||
show_progress_bar(screen);
|
||||
do_setcursor(cursor_watch);
|
||||
|
||||
if (!make_directory("", "Can't create user data directory"))
|
||||
if (!make_directory(DIR_SAVE, "", "Can't create user data directory"))
|
||||
{
|
||||
fprintf(stderr, "Cannot save the any pictures! SORRY!\n\n");
|
||||
draw_tux_text(TUX_OOPS, strerror(errno), 0);
|
||||
|
|
@ -13121,7 +13131,7 @@ static int do_save(int tool, int dont_show_success_results)
|
|||
|
||||
/* Make sure we have a ~/.tuxpaint/saved directory: */
|
||||
|
||||
if (!make_directory("saved", "Can't create user data directory"))
|
||||
if (!make_directory(DIR_SAVE, "saved", "Can't create user data directory"))
|
||||
{
|
||||
fprintf(stderr, "Cannot save any pictures! SORRY!\n\n");
|
||||
draw_tux_text(TUX_OOPS, strerror(errno), 0);
|
||||
|
|
@ -13133,7 +13143,7 @@ static int do_save(int tool, int dont_show_success_results)
|
|||
|
||||
/* Make sure we have a ~/.tuxpaint/saved/.thumbs/ directory: */
|
||||
|
||||
if (!make_directory("saved/.thumbs", "Can't create user data thumbnail directory"))
|
||||
if (!make_directory(DIR_SAVE, "saved/.thumbs", "Can't create user data thumbnail directory"))
|
||||
{
|
||||
fprintf(stderr, "Cannot save any pictures! SORRY!\n\n");
|
||||
draw_tux_text(TUX_OOPS, strerror(errno), 0);
|
||||
|
|
@ -13144,7 +13154,7 @@ static int do_save(int tool, int dont_show_success_results)
|
|||
|
||||
/* Make sure we have a ~/.tuxpaint/saved/.label/ directory: */
|
||||
|
||||
if (!make_directory("saved/.label", "Can't create label information directory"))
|
||||
if (!make_directory(DIR_SAVE, "saved/.label", "Can't create label information directory"))
|
||||
{
|
||||
fprintf(stderr, "Cannot save label information! SORRY!\n\n");
|
||||
draw_tux_text(TUX_OOPS, strerror(errno), 0);
|
||||
|
|
@ -14181,10 +14191,10 @@ static int do_open(void)
|
|||
/* No thumbnail - load original: */
|
||||
|
||||
/* Make sure we have a ~/.tuxpaint/saved directory: */
|
||||
if (make_directory("saved", "Can't create user data directory"))
|
||||
if (make_directory(DIR_SAVE, "saved", "Can't create user data directory"))
|
||||
{
|
||||
/* (Make sure we have a .../saved/.thumbs/ directory:) */
|
||||
make_directory("saved/.thumbs", "Can't create user data thumbnail directory");
|
||||
make_directory(DIR_SAVE, "saved/.thumbs", "Can't create user data thumbnail directory");
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -15202,10 +15212,10 @@ static int do_slideshow(void)
|
|||
/* No thumbnail - load original: */
|
||||
|
||||
/* Make sure we have a ~/.tuxpaint/saved directory: */
|
||||
if (make_directory("saved", "Can't create user data directory"))
|
||||
if (make_directory(DIR_SAVE, "saved", "Can't create user data directory"))
|
||||
{
|
||||
/* (Make sure we have a .../saved/.thumbs/ directory:) */
|
||||
make_directory("saved/.thumbs", "Can't create user data thumbnail directory");
|
||||
make_directory(DIR_SAVE, "saved/.thumbs", "Can't create user data thumbnail directory");
|
||||
}
|
||||
|
||||
snprintf(fname, sizeof(fname), "%s/%s", dirname, f->d_name);
|
||||
|
|
@ -15676,7 +15686,7 @@ static int do_slideshow(void)
|
|||
}
|
||||
else
|
||||
{
|
||||
/* FIXME: Do it */
|
||||
do_export_gif();
|
||||
}
|
||||
}
|
||||
else if (event.button.x >= (WINDOW_WIDTH - 96 - 48) &&
|
||||
|
|
@ -18998,11 +19008,11 @@ static int do_new_dialog(void)
|
|||
/* No thumbnail - load original: */
|
||||
|
||||
/* Make sure we have a ~/.tuxpaint/[starters|templates] directory: */
|
||||
if (make_directory(dirname[d_places[num_files]], "Can't create user data directory"))
|
||||
if (make_directory(DIR_SAVE, dirname[d_places[num_files]], "Can't create user data directory"))
|
||||
{
|
||||
/* (Make sure we have a .../[starters|templates]/.thumbs/ directory:) */
|
||||
snprintf(fname, sizeof(fname), "%s/.thumbs", dirname[d_places[num_files]]);
|
||||
make_directory(fname, "Can't create user data thumbnail directory");
|
||||
make_directory(DIR_SAVE, fname, "Can't create user data thumbnail directory");
|
||||
}
|
||||
|
||||
img = NULL;
|
||||
|
|
@ -19103,10 +19113,10 @@ static int do_new_dialog(void)
|
|||
snprintf(fname, sizeof(fname), "%s/.thumbs/%s-t.png",
|
||||
dirname[d_places[num_files]], d_names[num_files]);
|
||||
|
||||
if (!make_directory("starters", "Can't create user data directory") ||
|
||||
!make_directory("templates", "Can't create user data directory") ||
|
||||
!make_directory("starters/.thumbs", "Can't create user data directory") ||
|
||||
!make_directory("templates/.thumbs", "Can't create user data directory"))
|
||||
if (!make_directory(DIR_SAVE, "starters", "Can't create user data directory") ||
|
||||
!make_directory(DIR_SAVE, "templates", "Can't create user data directory") ||
|
||||
!make_directory(DIR_SAVE, "starters/.thumbs", "Can't create user data directory") ||
|
||||
!make_directory(DIR_SAVE, "templates/.thumbs", "Can't create user data directory"))
|
||||
fprintf(stderr, "Cannot save any pictures! SORRY!\n\n");
|
||||
else
|
||||
{
|
||||
|
|
@ -22565,6 +22575,7 @@ static void setup_config(char *argv[])
|
|||
}
|
||||
#endif
|
||||
|
||||
/* == SAVEDIR == */
|
||||
if (tmpcfg_cmd.savedir)
|
||||
savedir = strdup(tmpcfg_cmd.savedir);
|
||||
else
|
||||
|
|
@ -22595,6 +22606,23 @@ static void setup_config(char *argv[])
|
|||
#endif
|
||||
}
|
||||
|
||||
/* == EXPORTDIR == */
|
||||
if (tmpcfg_cmd.exportdir)
|
||||
exportdir = strdup(tmpcfg_cmd.exportdir);
|
||||
else
|
||||
{
|
||||
/* FIXME: Need assist for:
|
||||
* _WIN32
|
||||
* __BEOS__
|
||||
* __HAIKU__
|
||||
* __APPLE__
|
||||
*/
|
||||
exportdir = get_xdg_user_dir("PICTURES", "Pictures");
|
||||
}
|
||||
|
||||
printf("Export Dir = %s\n", exportdir);
|
||||
exit(0);
|
||||
|
||||
/* Load options from user's own configuration (".rc" / ".cfg") file: */
|
||||
|
||||
#if defined(_WIN32)
|
||||
|
|
@ -24635,6 +24663,7 @@ static int trash(char *path)
|
|||
|
||||
/* Move file into Trash folder */
|
||||
|
||||
/* FIXME: Use xdg function */
|
||||
if (getenv("XDG_DATA_HOME") != NULL)
|
||||
{
|
||||
sprintf(trashpath, "%s/Trash", getenv("XDG_DATA_HOME"));
|
||||
|
|
@ -25114,3 +25143,129 @@ static void handle_joybuttonupdownscl(SDL_Event event, int oldpos_x, int oldpos_
|
|||
if (!ignore)
|
||||
SDL_PushEvent(&ev);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grab the user's XDG user dir for something (e.g., ~/Pictures)
|
||||
*
|
||||
* @param char * dir_type -- the thing to query, e.g. "PICTURES" or "VIDEOS"
|
||||
* (note: currently, Tux Paint only puts things in the PICTURES one)
|
||||
* @param char * fallback -- path, under $HOME, to use instead (e.g., "Pictures")
|
||||
* @return char * path (caller is expected to free() it)
|
||||
*/
|
||||
char * get_xdg_user_dir(const char * dir_type, const char * fallback) {
|
||||
FILE * fi;
|
||||
char * config_home, * found;
|
||||
char tmp_path[MAX_PATH], config_path[MAX_PATH], line[MAX_PATH], search[MAX_PATH], return_path[MAX_PATH];
|
||||
int found_it;
|
||||
|
||||
found_it = FALSE;
|
||||
|
||||
/* Figure out where XDG user-dirs config exists, and use it if possible */
|
||||
if (getenv("XDG_CONFIG_HOME") != NULL) {
|
||||
config_home = strdup(getenv("XDG_CONFIG_HOME"));
|
||||
} else {
|
||||
#ifdef DEBUG
|
||||
fprintf(stderr, "XDG_CONFIG_HOME not set, checking $HOME/.config/\n");
|
||||
#endif
|
||||
if (getenv("HOME") != NULL) {
|
||||
snprintf(tmp_path, MAX_PATH, "%s/.config", getenv("HOME"));
|
||||
config_home = strdup(tmp_path);
|
||||
} else {
|
||||
#ifdef DEBUG
|
||||
fprintf(stderr, "No HOME, either?! Returing fallback in current directory\n");
|
||||
#endif
|
||||
return strdup(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
if (config_home[strlen(config_home) - 1] == '/') {
|
||||
config_home[strlen(config_home) - 1] = '\0';
|
||||
}
|
||||
snprintf(config_path, MAX_PATH, "%s/user-dirs.dirs", config_home);
|
||||
free(config_home);
|
||||
|
||||
#ifdef DEBUG
|
||||
fprintf(stderr, "User dirs config = %s\n", config_path);
|
||||
#endif
|
||||
|
||||
snprintf(search, MAX_PATH, "XDG_%s_DIR=\"", dir_type);
|
||||
|
||||
/* Read the config to find the path we want */
|
||||
fi = fopen(config_path, "r");
|
||||
if (fi != NULL) {
|
||||
/* Search for a line in the form of either
|
||||
either XDG_PICTURES_DIR="$HOME/Pictures"
|
||||
or XDG_PICTURES_DIR="/Path/To/Pictures"
|
||||
*/
|
||||
#ifdef DEBUG
|
||||
fprintf(stderr, "Searching it for: %s\n", search);
|
||||
#endif
|
||||
while (fgets(line, MAX_PATH, fi) && !found_it) {
|
||||
/* Trim trailing CR/LF */
|
||||
if (line[strlen(line) - 1] == '\n' ||
|
||||
line[strlen(line) - 1] == '\r') {
|
||||
line[strlen(line) - 1] = '\0';
|
||||
}
|
||||
|
||||
if (strstr(line, search) == line) {
|
||||
found = line + strlen(search);
|
||||
#ifdef DEBUG
|
||||
fprintf(stderr, "Found it: %s\n", found);
|
||||
#endif
|
||||
if (strstr(found, "$HOME/") == found) {
|
||||
snprintf(return_path, MAX_PATH, "%s/%s", getenv("HOME"), found + 6 /* skip '$HOME/' */);
|
||||
} else {
|
||||
strcpy(return_path, found);
|
||||
}
|
||||
|
||||
/* Trim trailing " */
|
||||
if (return_path[strlen(return_path) - 1] == '\"') {
|
||||
return_path[strlen(return_path) - 1] = '\0';
|
||||
}
|
||||
|
||||
found_it = TRUE;
|
||||
}
|
||||
}
|
||||
|
||||
fclose(fi);
|
||||
#ifdef DEBUG
|
||||
} else {
|
||||
fprintf(stderr, "%s doesn't exist\n", config_path);
|
||||
#endif
|
||||
}
|
||||
|
||||
if (!found_it) {
|
||||
#ifdef DEBUG
|
||||
fprintf(stderr, "Using fallback of $HOME/%s\n", fallback);
|
||||
#endif
|
||||
snprintf(return_path, MAX_PATH, "%s/%s", getenv("HOME"), fallback);
|
||||
}
|
||||
|
||||
#ifdef DEBUG
|
||||
fprintf(stderr, "Location for %s => %s\n", dir_type, return_path);
|
||||
#endif
|
||||
|
||||
return strdup(return_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* After 2+ images have been selected in the Open->Slideshow
|
||||
* dialog, they can be exported as an animated GIF.
|
||||
*/
|
||||
void do_export_gif(void) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user's chosen export directory
|
||||
* for animated GIFs, via Open->Slideshow dialog,
|
||||
* and static PNGs, via Open dialog */
|
||||
char * get_export_filepath(void) {
|
||||
char *rname;
|
||||
char fname[FILENAME_MAX];
|
||||
|
||||
rname = NULL;
|
||||
/*
|
||||
snprintf(fname, sizeof(fname), "saved/%s.dat", saved_id);
|
||||
rname = get_fname(fname, DIR_SAVE);
|
||||
*/
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
# http://www.tuxpaint.org/
|
||||
#
|
||||
# Default distribution version last modified:
|
||||
# September 21, 2019
|
||||
# July 25, 2020
|
||||
#
|
||||
# $Id$
|
||||
|
||||
|
|
@ -251,6 +251,13 @@
|
|||
# savedir=~/.tuxpaint
|
||||
|
||||
|
||||
### Export images and animated GIF slideshows somewhere different?
|
||||
### (Uses xdg-user-dirs config setting for PICTURES, if available!)
|
||||
### --------------------------------------------------------------
|
||||
#
|
||||
# exportdir=~/Pictures
|
||||
|
||||
|
||||
### Use a different language?
|
||||
### -------------------------
|
||||
### Note: Where the language is a known language name (e.g., "spanish")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue