Bring the full EditmaskwithAI application into the repo under paintplus/ (429 files) so the service is self-contained — the installer copies the vendored source to ~/docker/paintplus/src instead of cloning at runtime. Rename to PaintPlus (service + branding; app logic untouched): - services/editmaskwithai.sh -> services/paintplus.sh (register_service paintplus, install_paintplus, ~/docker/paintplus, Caddy paintplus:8000, Authelia option preserved) - container names -> paintplus across docker-compose*.yml; dev network -> paintplus-network - browser <title> -> "PaintPlus - AI Image Editor"; README heading -> PaintPlus with upstream provenance note - README utilities table: editmaskwithai -> paintplus Backend/frontend code (help strings referencing the old container name, the ai_photo_edit.db filename) is intentionally left as-is to avoid touching application logic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nb2vJ8W7bHKx1JXVvpCraH
709 lines
18 KiB
JavaScript
709 lines
18 KiB
JavaScript
import app from './../../app.js';
|
|
import config from './../../config.js';
|
|
import Base_layers_class from './../../core/base-layers.js';
|
|
import Base_gui_class from './../../core/base-gui.js';
|
|
import Dialog_class from './../../libs/popup.js';
|
|
import Helper_class from './../../libs/helpers.js';
|
|
import Clipboard_class from './../../libs/clipboard.js';
|
|
import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
|
|
import EXIF from './../../../../node_modules/exif-js/exif.js';
|
|
import GUI_tools_class from "../../core/gui/gui-tools";
|
|
import semver_compare from './../../../../node_modules/semver-compare/';
|
|
|
|
var instance = null;
|
|
|
|
/**
|
|
* manages files / open
|
|
*
|
|
* @author ViliusL
|
|
*/
|
|
class File_open_class {
|
|
|
|
constructor() {
|
|
//singleton
|
|
if (instance) {
|
|
return instance;
|
|
}
|
|
instance = this;
|
|
|
|
var _this = this;
|
|
this.POP = new Dialog_class();
|
|
this.Base_layers = new Base_layers_class();
|
|
this.Base_gui = new Base_gui_class();
|
|
this.Helper = new Helper_class();
|
|
this.GUI_tools = new GUI_tools_class();
|
|
|
|
//clipboard class
|
|
this.Clipboard_class = new Clipboard_class(function (data, w, h) {
|
|
_this.on_paste(data, w, h);
|
|
});
|
|
|
|
this.events();
|
|
|
|
this.maybe_file_open_url_handler();
|
|
}
|
|
|
|
events() {
|
|
var _this = this;
|
|
|
|
window.ondrop = function (e) {
|
|
//drop
|
|
e.preventDefault();
|
|
_this.open_handler(e);
|
|
};
|
|
window.ondragover = function (e) {
|
|
e.preventDefault();
|
|
};
|
|
document.addEventListener('keydown', (event) => {
|
|
var code = event.key.toLowerCase();
|
|
if (this.Helper.is_input(event.target))
|
|
return;
|
|
|
|
if (code == "o") {
|
|
//open
|
|
this.open_file();
|
|
event.preventDefault();
|
|
}
|
|
}, false);
|
|
}
|
|
|
|
on_paste(data, width, height) {
|
|
var new_layer = {
|
|
name: 'Paste',
|
|
type: 'image',
|
|
data: data,
|
|
};
|
|
app.State.do_action(
|
|
new app.Actions.Insert_layer_action(new_layer)
|
|
);
|
|
}
|
|
|
|
open_file() {
|
|
var _this = this;
|
|
|
|
alertify.success('You can also drag and drop items into browser.');
|
|
|
|
document.getElementById("tmp").innerHTML = '';
|
|
var a = document.createElement('input');
|
|
a.setAttribute("id", "file_open");
|
|
a.type = 'file';
|
|
a.multiple = 'multiple';
|
|
document.getElementById("tmp").appendChild(a);
|
|
document.getElementById('file_open').addEventListener('change', function (e) {
|
|
_this.open_handler(e);
|
|
}, false);
|
|
|
|
//force click
|
|
document.querySelector('#file_open').click();
|
|
}
|
|
|
|
open_webcam(){
|
|
var _this = this;
|
|
var video = document.createElement('video');
|
|
video.autoplay = true;
|
|
video.style.maxWidth = '100%';
|
|
var track = null;
|
|
|
|
function handleSuccess(stream) {
|
|
track = stream.getTracks()[0];
|
|
video.srcObject = stream;
|
|
}
|
|
|
|
function handleError(error) {
|
|
alertify.error('Sorry, cold not load getUserMedia() data: ' + error);
|
|
}
|
|
|
|
var settings = {
|
|
title: 'Webcam',
|
|
params: [
|
|
{title: "Stream:", html: '<div id="webcam_container"></div>'},
|
|
],
|
|
on_load: function(params){
|
|
document.getElementById('webcam_container').appendChild(video);
|
|
},
|
|
on_finish: function(params){
|
|
//capture data
|
|
var width = video.videoWidth;
|
|
var height = video.videoHeight;
|
|
|
|
var tmpCanvas = document.createElement('canvas');
|
|
var tmpCanvasCtx = tmpCanvas.getContext("2d");
|
|
tmpCanvas.width = width;
|
|
tmpCanvas.height = height;
|
|
tmpCanvasCtx.drawImage(video, 0, 0);
|
|
|
|
//create requested layer
|
|
var new_layer = {
|
|
name: "Webcam #" + _this.Base_layers.auto_increment,
|
|
type: 'image',
|
|
data: tmpCanvas.toDataURL("image/png"),
|
|
width: width,
|
|
height: height,
|
|
width_original: width,
|
|
height_original: height,
|
|
};
|
|
app.State.do_action(
|
|
new app.Actions.Bundle_action('open_file_webcam', 'Open File Webcam', [
|
|
new app.Actions.Insert_layer_action(new_layer),
|
|
new app.Actions.Autoresize_canvas_action(width, height, null, true, true)
|
|
])
|
|
);
|
|
|
|
//destroy
|
|
if(track != null){
|
|
track.stop();
|
|
}
|
|
video.pause();
|
|
video.src = "";
|
|
video.load();
|
|
},
|
|
on_cancel: function(params){
|
|
if(track != null){
|
|
track.stop();
|
|
}
|
|
video.pause();
|
|
video.src = "";
|
|
video.load();
|
|
},
|
|
};
|
|
this.POP.show(settings);
|
|
|
|
navigator.mediaDevices.getUserMedia({audio: false, video: true})
|
|
.then(handleSuccess)
|
|
.catch(handleError);
|
|
}
|
|
|
|
open_dir() {
|
|
var _this = this;
|
|
|
|
document.getElementById("tmp").innerHTML = '';
|
|
var a = document.createElement('input');
|
|
a.setAttribute("id", "file_open_dir");
|
|
a.type = 'file';
|
|
a.webkitdirectory = 'webkitdirectory';
|
|
document.getElementById("tmp").appendChild(a);
|
|
document.getElementById('file_open_dir').addEventListener('change', function (e) {
|
|
_this.open_handler(e);
|
|
}, false);
|
|
|
|
//force click
|
|
document.querySelector('#file_open_dir').click();
|
|
}
|
|
|
|
/**
|
|
* opens data URLs, like: "data:image/png;base64,xxxxxx"
|
|
*
|
|
* data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAG0lEQVQYV2N89+7df0FBQQbG/////3///j0DAF9wCsg9spQfAAAAAElFTkSuQmCC
|
|
*/
|
|
open_data_url() {
|
|
var _this = this;
|
|
|
|
var settings = {
|
|
title: 'Open data URL',
|
|
params: [
|
|
{name: "data", title: "Data URL:", type: "textarea", value: ""},
|
|
],
|
|
on_finish: function (params) {
|
|
_this.file_open_data_url_handler(params.data);
|
|
},
|
|
};
|
|
this.POP.show(settings);
|
|
}
|
|
|
|
file_open_data_url_handler(data) {
|
|
var _this = this;
|
|
if (data == '')
|
|
return;
|
|
|
|
var img = new Image();
|
|
img.crossOrigin = "Anonymous";
|
|
img.onload = function () {
|
|
var new_layer = {
|
|
name: "Data URL",
|
|
type: 'image',
|
|
link: img,
|
|
width: img.width,
|
|
height: img.height,
|
|
width_original: img.width,
|
|
height_original: img.height,
|
|
};
|
|
app.State.do_action(
|
|
new app.Actions.Bundle_action('open_file_data_url', 'Open File Data URL', [
|
|
new app.Actions.Insert_layer_action(new_layer),
|
|
new app.Actions.Autoresize_canvas_action(img.width, img.height, null, true, true)
|
|
])
|
|
);
|
|
img.onload = function () {
|
|
config.need_render = true;
|
|
};
|
|
};
|
|
img.onerror = function (ex) {
|
|
alertify.error('Sorry, image could not be loaded. Try copy image and paste it.');
|
|
};
|
|
img.src = data;
|
|
}
|
|
|
|
open_url() {
|
|
var _this = this;
|
|
|
|
var settings = {
|
|
title: 'Open URL',
|
|
params: [
|
|
{name: "url", title: "URL:", value: ""},
|
|
],
|
|
on_finish: function (params) {
|
|
_this.file_open_url_handler(params);
|
|
},
|
|
};
|
|
this.POP.show(settings);
|
|
}
|
|
|
|
async open_handler(e) {
|
|
var _this = this;
|
|
var files = e.target.files;
|
|
|
|
var auto_increment = this.Base_layers.auto_increment;
|
|
|
|
if (files == undefined) {
|
|
//drag and drop
|
|
files = e.dataTransfer.files;
|
|
}
|
|
|
|
//sort
|
|
var orders = [];
|
|
for (var i = 0, f; i < files.length; i++) {
|
|
orders.push(files[i].name);
|
|
}
|
|
orders.sort();
|
|
var order_map = [];
|
|
for (var i in orders) {
|
|
order_map[orders[i]] = parseInt(i);
|
|
}
|
|
|
|
//check if dropped directory
|
|
var dir_opened = false;
|
|
if (e.dataTransfer && e.dataTransfer.items) {
|
|
var items = e.dataTransfer.items;
|
|
for (var i=0; i<items.length; i++) {
|
|
var item = items[i].webkitGetAsEntry();
|
|
if(item && item.isDirectory){
|
|
dir_opened = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (var i = 0, f; i < files.length; i++) {
|
|
f = files[i];
|
|
if (!f.type.match('image.*') && !f.name.match('.json')) {
|
|
if(dir_opened == false) {
|
|
alertify.error('Wrong file type, must be image or json.');
|
|
}
|
|
continue;
|
|
}
|
|
if (files.length == 1) {
|
|
this.SAVE_NAME = f.name.split('.')[f.name.split('.').length - 2];
|
|
}
|
|
|
|
var FR = new FileReader();
|
|
FR.file = files[i];
|
|
|
|
FR.onload = function (event) {
|
|
if (this.file.type.match('image.*')) {
|
|
var order = auto_increment + order_map[this.file.name];
|
|
//image
|
|
var new_layer = {
|
|
name: this.file.name,
|
|
type: 'image',
|
|
data: event.target.result,
|
|
order: order,
|
|
_exif: _this.extract_exif(this.file)
|
|
};
|
|
app.State.do_action(
|
|
new app.Actions.Bundle_action('open_image', 'Open Image', [
|
|
new app.Actions.Insert_layer_action(new_layer)
|
|
])
|
|
);
|
|
}
|
|
else {
|
|
//json
|
|
var response = _this.load_json(event.target.result);
|
|
if (response === true) {
|
|
return false;
|
|
}
|
|
}
|
|
};
|
|
if (f.type == "text/plain")
|
|
FR.readAsText(f);
|
|
else if (f.name.match('.json'))
|
|
FR.readAsText(f);
|
|
else
|
|
FR.readAsDataURL(f);
|
|
|
|
//sleep after last image import, it maybe not be finished yet
|
|
await new Promise(r => setTimeout(r, 10));
|
|
}
|
|
|
|
//try to open dropped directory
|
|
if (e.dataTransfer && e.dataTransfer.items) {
|
|
var items = e.dataTransfer.items;
|
|
for (var i=0; i<items.length; i++) {
|
|
var item = items[i].webkitGetAsEntry();
|
|
if (item && item.isDirectory == true) {
|
|
this.traverseFileTree(item);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
traverseFileTree(item, path) {
|
|
var _this = this;
|
|
var auto_increment = this.Base_layers.auto_increment;
|
|
|
|
path = path || "";
|
|
if (item.isFile) {
|
|
item.file(async function(file) {
|
|
var FR = new FileReader();
|
|
FR.file = file;
|
|
|
|
FR.onload = function (event) {
|
|
if (this.file.type.match('image.*')
|
|
//below is fix for firefox, it has empty type
|
|
|| (this.file.type == '' && this.file.name.match(/\.(png|jpg|jpeg|webp|gif|avif)/g))) {
|
|
//image
|
|
var new_layer = {
|
|
name: this.file.name,
|
|
type: 'image',
|
|
data: event.target.result,
|
|
_exif: _this.extract_exif(this.file)
|
|
};
|
|
app.State.do_action(
|
|
new app.Actions.Bundle_action('open_image', 'Open Image', [
|
|
new app.Actions.Insert_layer_action(new_layer)
|
|
])
|
|
);
|
|
}
|
|
};
|
|
|
|
FR.readAsDataURL(file);
|
|
|
|
//sleep after last image import, it maybe not be finished yet
|
|
await new Promise(r => setTimeout(r, 10));
|
|
|
|
});
|
|
}
|
|
else if (item.isDirectory) {
|
|
// Get folder contents
|
|
var dirReader = item.createReader();
|
|
dirReader.readEntries(function(entries) {
|
|
for (var i=0; i<entries.length; i++) {
|
|
_this.traverseFileTree(entries[i], path + item.name + "/");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
open_template_test(){
|
|
var _this = this;
|
|
|
|
this.Base_layers.debug_rendering = true;
|
|
|
|
window.fetch("images/test-collection.json").then(function(response) {
|
|
return response.json();
|
|
}).then(function(json) {
|
|
_this.load_json(json, false);
|
|
}).catch(function(ex) {
|
|
alertify.error('Sorry, image could not be loaded.');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* check if url has url params, for example: https://viliusle.github.io/miniPaint/?image=http://i.imgur.com/ATda8Ae.jpg
|
|
*/
|
|
maybe_file_open_url_handler() {
|
|
var _this = this;
|
|
var url_params = this.Helper.get_url_parameters();
|
|
|
|
if (url_params.image != undefined) {
|
|
this.open_resource(url_params.image);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* includes provided resource (image or json)
|
|
*
|
|
* @param string resource_url
|
|
*/
|
|
open_resource(resource_url) {
|
|
var _this = this;
|
|
|
|
if(resource_url.toLowerCase().indexOf('.json') == resource_url.length - 5){
|
|
//load json
|
|
window.fetch(resource_url).then(function(response) {
|
|
return response.json();
|
|
}).then(function(json) {
|
|
_this.load_json(json, false);
|
|
}).catch(function(ex) {
|
|
alertify.error('Sorry, image could not be loaded.');
|
|
});
|
|
}
|
|
else{
|
|
//load image
|
|
var data = {
|
|
url: resource_url,
|
|
};
|
|
this.file_open_url_handler(data);
|
|
}
|
|
}
|
|
|
|
//handler for open url. Example url: http://i.imgur.com/ATda8Ae.jpg
|
|
file_open_url_handler(user_response) {
|
|
var _this = this;
|
|
var url = user_response.url;
|
|
if (url == '')
|
|
return;
|
|
|
|
var layer_name = url.replace(/^.*[\\\/]/, '');
|
|
|
|
var img = new Image();
|
|
img.crossOrigin = "Anonymous";
|
|
img.onload = function () {
|
|
var new_layer = {
|
|
name: layer_name,
|
|
type: 'image',
|
|
link: img,
|
|
width: img.width,
|
|
height: img.height,
|
|
width_original: img.width,
|
|
height_original: img.height,
|
|
};
|
|
img.onload = function () {
|
|
config.need_render = true;
|
|
};
|
|
app.State.do_action(
|
|
new app.Actions.Bundle_action('open_file_url', 'Open File URL', [
|
|
new app.Actions.Insert_layer_action(new_layer),
|
|
new app.Actions.Autoresize_canvas_action(img.width, img.height, null, true, true)
|
|
])
|
|
);
|
|
};
|
|
img.onerror = function (ex) {
|
|
alertify.error('Sorry, image could not be loaded. Try copy image and paste it.');
|
|
};
|
|
img.src = url;
|
|
}
|
|
|
|
async load_json(data) {
|
|
var json;
|
|
if(typeof data == 'string')
|
|
json = JSON.parse(data);
|
|
else
|
|
json = data;
|
|
if (json.info.version == undefined) {
|
|
json.info.version = "3.0.0";
|
|
}
|
|
|
|
//migration
|
|
if(semver_compare(json.info.version, '4.0.0') < 0) {
|
|
//convert from v3 to v4
|
|
for (var i in json.layers) {
|
|
//layers data
|
|
json.layers[i].id = (parseInt(i) + 1);
|
|
json.layers[i].opacity = json.layers[i].opacity * 100 || 100;
|
|
json.layers[i].type = "image";
|
|
json.layers[i].width = json.info.width;
|
|
json.layers[i].height = json.info.height;
|
|
json.layers[i].visible = (json.layers[i].visible == true); //convert to boolean
|
|
delete json.layers[i].title;
|
|
}
|
|
json.data = [];
|
|
for (var i in json.image_data) {
|
|
//image data
|
|
var new_id = null;
|
|
for (var j in json.layers) {
|
|
if (json.layers[j].name == json.image_data[i].name) {
|
|
new_id = json.layers[j].id;
|
|
}
|
|
}
|
|
if (new_id == null)
|
|
continue;
|
|
json.data.push(
|
|
{
|
|
id: new_id,
|
|
data: json.image_data[i].data,
|
|
}
|
|
);
|
|
}
|
|
}
|
|
if(semver_compare(json.info.version, '4.5.0') < 0) {
|
|
//migrate "rectangle", "circle" and "line" types to "shape"
|
|
for (var i in json.layers) {
|
|
var old_type = json.layers[i].type;
|
|
|
|
if(old_type == 'line' && json.layers[i].params.type.value == "Arrow"){
|
|
//migrate line (type=arrow) to arrow.
|
|
json.layers[i].type = 'arrow';
|
|
delete json.layers[i].params.type;
|
|
json.layers[i].render_function = ["arrow", "render"];
|
|
}
|
|
if(old_type == 'rectangle' || old_type == 'circle'){
|
|
//migrate params
|
|
json.layers[i].params.border_size = json.layers[i].params.size;
|
|
delete json.layers[i].params.size;
|
|
|
|
if(json.layers[i].params.fill == true) {
|
|
json.layers[i].params.border = false;
|
|
}
|
|
else{
|
|
json.layers[i].params.border = true;
|
|
}
|
|
json.layers[i].params.border_color = json.layers[i].color;
|
|
json.layers[i].params.fill_color = json.layers[i].color;
|
|
|
|
json.layers[i].color = null;
|
|
}
|
|
if(old_type == 'circle'){
|
|
//rename circle to ellipse
|
|
json.layers[i].type = 'ellipse';
|
|
json.layers[i].render_function = ["ellipse", "render"];
|
|
}
|
|
}
|
|
}
|
|
if(semver_compare(json.info.version, '4.8.0') < 0) {
|
|
//migrate "borders" layer to rectangle
|
|
for (var i in json.layers) {
|
|
var old_type = json.layers[i].type;
|
|
|
|
if(old_type == 'borders'){
|
|
json.layers[i].type = 'rectangle';
|
|
json.layers[i].name += ' (legacy)';
|
|
json.layers[i].params = {
|
|
radius: 0,
|
|
fill: false,
|
|
square: false,
|
|
border_size: json.layers[i].params.size,
|
|
border: true,
|
|
border_color: json.layers[i].color,
|
|
fill_color: "#000000",
|
|
};
|
|
json.layers[i].render_function = ["rectangle", "render"];
|
|
}
|
|
}
|
|
}
|
|
if(semver_compare(json.info.version, '4.11.0') < 0) {
|
|
//migrate star and star24 objects
|
|
for (var i in json.layers) {
|
|
var old_type = json.layers[i].type;
|
|
|
|
if(old_type == 'star' && typeof json.layers[i].params.corners == "undefined"){
|
|
json.layers[i].params.corners = 5;
|
|
json.layers[i].params.inner_radius = 40;
|
|
json.layers[i].render_function = ["star", "render"];
|
|
}
|
|
else if(old_type == 'star24'){
|
|
json.layers[i].type = 'star';
|
|
json.layers[i].params.corners = 24;
|
|
json.layers[i].params.inner_radius = 80;
|
|
json.layers[i].render_function = ["star", "render"];
|
|
}
|
|
}
|
|
}
|
|
|
|
const actions = [];
|
|
|
|
//reset zoom
|
|
await this.Base_gui.GUI_preview.zoom(100); //reset zoom
|
|
|
|
//set attributes
|
|
actions.push(
|
|
new app.Actions.Refresh_action_attributes_action('undo'),
|
|
new app.Actions.Prepare_canvas_action('undo'),
|
|
new app.Actions.Update_config_action({
|
|
ZOOM: 1,
|
|
WIDTH: parseInt(json.info.width),
|
|
HEIGHT: parseInt(json.info.height),
|
|
user_fonts: json.user_fonts || {}
|
|
}),
|
|
new app.Actions.Reset_layers_action(),
|
|
new app.Actions.Prepare_canvas_action('do'),
|
|
new app.Actions.Refresh_action_attributes_action('do')
|
|
);
|
|
|
|
var max_id_order = 0;
|
|
for (var i in json.layers) {
|
|
var value = json.layers[i];
|
|
|
|
if(value.id > max_id_order)
|
|
max_id_order = value.id;
|
|
if(typeof value.order != undefined && value.order > max_id_order)
|
|
max_id_order = value.order;
|
|
|
|
if (value.type == 'image') {
|
|
//add image data
|
|
value.link = null;
|
|
for (var j in json.data) {
|
|
if (json.data[j].id == value.id) {
|
|
value.data = json.data[j].data;
|
|
}
|
|
}
|
|
}
|
|
actions.push(
|
|
new app.Actions.Insert_layer_action(value, false)
|
|
);
|
|
}
|
|
if (json.info.layer_active != undefined) {
|
|
actions.push(
|
|
new app.Actions.Select_layer_action(json.info.layer_active, true)
|
|
);
|
|
}
|
|
if (json.info.guides != undefined) {
|
|
config.guides = json.info.guides;
|
|
}
|
|
actions.push(
|
|
new app.Actions.Set_object_property_action(this.Base_layers, 'auto_increment', max_id_order + 1),
|
|
new app.Actions.Update_config_action({
|
|
WIDTH: parseInt(json.info.width),
|
|
HEIGHT: parseInt(json.info.height),
|
|
}),
|
|
new app.Actions.Prepare_canvas_action('do')
|
|
);
|
|
await app.State.do_action(
|
|
new app.Actions.Bundle_action('open_json_file', 'Open JSON File', actions)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns an action that saves the exif data of the provided object to the current layer
|
|
*/
|
|
extract_exif(object) {
|
|
var exif_data = {
|
|
general: [],
|
|
exif: [],
|
|
};
|
|
|
|
//exif data
|
|
EXIF.getData(object, function () {
|
|
exif_data.exif = this.exifdata;
|
|
delete this.exifdata.thumbnail;
|
|
});
|
|
|
|
//general
|
|
if (object.name != undefined)
|
|
exif_data.general.Name = object.name;
|
|
if (object.size != undefined)
|
|
exif_data.general.Size = this.Helper.number_format(object.size / 1000, 2) + ' KB';
|
|
if (object.type != undefined)
|
|
exif_data.general.Type = object.type;
|
|
if (object.lastModified != undefined)
|
|
exif_data.general['Last modified'] = this.Helper.format_time(object.lastModified);
|
|
|
|
return exif_data;
|
|
}
|
|
|
|
search(){
|
|
this.GUI_tools.activate_tool('media');
|
|
}
|
|
}
|
|
|
|
export default File_open_class;
|
|
|