2020-11-05 18:58:23 +02:00
import Plugin from './Plugin' ;
import manifestFromObject from './utils/manifestFromObject' ;
import Global from './api/Global' ;
import BasePluginRunner from './BasePluginRunner' ;
2021-01-23 17:51:19 +02:00
import BaseService from '../BaseService' ;
2020-11-05 18:58:23 +02:00
import shim from '../../shim' ;
2020-11-20 01:46:04 +02:00
import { filename , dirname , rtrimSlashes } from '../../path-utils' ;
2020-11-17 20:26:24 +02:00
import Setting from '../../models/Setting' ;
2023-07-27 17:05:56 +02:00
import Logger from '@joplin/utils/Logger' ;
2021-01-07 18:30:53 +02:00
import RepositoryApi from './RepositoryApi' ;
2021-01-20 00:58:09 +02:00
import produce from 'immer' ;
2023-09-27 01:01:52 +02:00
import { compareVersions } from 'compare-versions' ;
2021-12-20 17:08:43 +02:00
const uslug = require ( '@joplin/fork-uslug' ) ;
2020-10-09 19:35:46 +02:00
2020-11-19 17:25:02 +02:00
const logger = Logger . create ( 'PluginService' ) ;
2020-11-19 14:34:49 +02:00
// Plugin data is split into two:
//
// - First there's the service `plugins` property, which contains the
// plugin static data, as loaded from the plugin file or directory. For
// example, the plugin ID, the manifest, the script files, etc.
//
// - Secondly, there's the `PluginSettings` data, which is dynamic and is
// used for example to enable or disable a plugin. Its state is saved to
// the user's settings.
export interface Plugins {
2020-11-12 21:29:22 +02:00
[ key : string ] : Plugin ;
2020-10-09 19:35:46 +02:00
}
2022-09-01 12:44:33 +02:00
export interface SettingAndValue {
[ settingName : string ] : string ;
}
export interface DefaultPluginSettings {
settings? : SettingAndValue ;
}
export interface DefaultPluginsInfo {
2023-07-06 20:17:41 +02:00
[ pluginId : string ] : DefaultPluginSettings ;
2022-09-01 12:44:33 +02:00
}
2020-11-19 14:34:49 +02:00
export interface PluginSetting {
enabled : boolean ;
deleted : boolean ;
2021-01-20 00:58:09 +02:00
// After a plugin has been updated, the user needs to restart the app before
// loading the new version. In the meantime, we set this property to `true`
// so that we know the plugin has been updated. It is used for example to
// disable the Update button.
hasBeenUpdated : boolean ;
2020-11-19 14:34:49 +02:00
}
export function defaultPluginSetting ( ) : PluginSetting {
return {
enabled : true ,
deleted : false ,
2021-01-20 00:58:09 +02:00
hasBeenUpdated : false ,
2020-11-19 14:34:49 +02:00
} ;
}
export interface PluginSettings {
[ pluginId : string ] : PluginSetting ;
}
2023-12-22 13:31:57 +02:00
interface PluginLoadOptions {
devMode : boolean ;
builtIn : boolean ;
}
2020-11-12 21:13:28 +02:00
function makePluginId ( source : string ) : string {
2020-10-09 19:35:46 +02:00
// https://www.npmjs.com/package/slug#options
2023-01-11 20:37:22 +02:00
return uslug ( source ) . substr ( 0 , 32 ) ;
2020-10-09 19:35:46 +02:00
}
export default class PluginService extends BaseService {
2020-11-12 21:13:28 +02:00
private static instance_ : PluginService = null ;
2020-10-09 19:35:46 +02:00
2020-11-12 21:13:28 +02:00
public static instance ( ) : PluginService {
2020-10-09 19:35:46 +02:00
if ( ! this . instance_ ) {
this . instance_ = new PluginService ( ) ;
}
return this . instance_ ;
}
2020-11-15 16:18:46 +02:00
private appVersion_ : string ;
2020-11-12 21:13:28 +02:00
private store_ : any = null ;
private platformImplementation_ : any = null ;
private plugins_ : Plugins = { } ;
private runner_ : BasePluginRunner = null ;
2021-02-07 18:47:56 +02:00
private startedPlugins_ : Record < string , boolean > = { } ;
2023-06-30 10:07:03 +02:00
private isSafeMode_ = false ;
2020-10-09 19:35:46 +02:00
2021-01-12 01:33:10 +02:00
public initialize ( appVersion : string , platformImplementation : any , runner : BasePluginRunner , store : any ) {
2020-11-15 16:18:46 +02:00
this . appVersion_ = appVersion ;
2020-10-09 19:35:46 +02:00
this . store_ = store ;
this . runner_ = runner ;
this . platformImplementation_ = platformImplementation ;
}
2020-11-12 21:13:28 +02:00
public get plugins ( ) : Plugins {
2020-10-09 19:35:46 +02:00
return this . plugins_ ;
}
2023-04-03 18:01:06 +02:00
public enabledPlugins ( pluginSettings : PluginSettings ) : Plugins {
const enabledPlugins = Object . fromEntries ( Object . entries ( this . plugins_ ) . filter ( ( p ) = > this . pluginEnabled ( pluginSettings , p [ 0 ] ) ) ) ;
return enabledPlugins ;
}
2024-02-03 21:28:47 +02:00
public isPluginLoaded ( pluginId : string ) {
return ! ! this . plugins_ [ pluginId ] ;
}
2021-08-05 13:48:39 +02:00
public get pluginIds ( ) : string [ ] {
return Object . keys ( this . plugins_ ) ;
}
2021-04-24 20:23:33 +02:00
public get isSafeMode ( ) : boolean {
return this . isSafeMode_ ;
}
2023-03-17 10:50:51 +02:00
public get appVersion ( ) : string {
return this . appVersion_ ;
}
2021-04-24 20:23:33 +02:00
public set isSafeMode ( v : boolean ) {
this . isSafeMode_ = v ;
}
2020-11-19 14:34:49 +02:00
private setPluginAt ( pluginId : string , plugin : Plugin ) {
this . plugins_ = {
. . . this . plugins_ ,
[ pluginId ] : plugin ,
} ;
}
private deletePluginAt ( pluginId : string ) {
if ( ! this . plugins_ [ pluginId ] ) return ;
this . plugins_ = { . . . this . plugins_ } ;
delete this . plugins_ [ pluginId ] ;
}
2021-01-20 00:58:09 +02:00
private async deletePluginFiles ( plugin : Plugin ) {
await shim . fsDriver ( ) . remove ( plugin . baseDir ) ;
}
2020-11-12 21:13:28 +02:00
public pluginById ( id : string ) : Plugin {
2020-10-09 19:35:46 +02:00
if ( ! this . plugins_ [ id ] ) throw new Error ( ` Plugin not found: ${ id } ` ) ;
return this . plugins_ [ id ] ;
}
2020-11-19 14:34:49 +02:00
public unserializePluginSettings ( settings : any ) : PluginSettings {
const output = { . . . settings } ;
for ( const pluginId in output ) {
output [ pluginId ] = {
. . . defaultPluginSetting ( ) ,
. . . output [ pluginId ] ,
} ;
}
return output ;
}
public serializePluginSettings ( settings : PluginSettings ) : any {
return JSON . stringify ( settings ) ;
}
2020-11-13 19:09:28 +02:00
2021-01-12 01:33:10 +02:00
public pluginIdByContentScriptId ( contentScriptId : string ) : string {
for ( const pluginId in this . plugins_ ) {
const plugin = this . plugins_ [ pluginId ] ;
const contentScript = plugin . contentScriptById ( contentScriptId ) ;
if ( contentScript ) return pluginId ;
}
return null ;
}
2020-11-12 21:13:28 +02:00
private async parsePluginJsBundle ( jsBundleString : string ) {
2020-10-13 12:16:36 +02:00
const scriptText = jsBundleString ;
const lines = scriptText . split ( '\n' ) ;
2020-11-12 21:13:28 +02:00
const manifestText : string [ ] = [ ] ;
2020-10-13 12:16:36 +02:00
const StateStarted = 1 ;
const StateInManifest = 2 ;
2020-11-12 21:13:28 +02:00
let state : number = StateStarted ;
2020-10-13 12:16:36 +02:00
for ( let line of lines ) {
line = line . trim ( ) ;
if ( state !== StateInManifest ) {
if ( line === '/* joplin-manifest:' ) {
state = StateInManifest ;
}
continue ;
}
if ( state === StateInManifest ) {
if ( line . indexOf ( '*/' ) === 0 ) {
break ;
} else {
manifestText . push ( line ) ;
}
}
}
if ( ! manifestText . length ) throw new Error ( 'Could not find manifest' ) ;
return {
scriptText : scriptText ,
manifestText : manifestText.join ( '\n' ) ,
} ;
}
2023-06-30 10:11:26 +02:00
public async loadPluginFromJsBundle ( baseDir : string , jsBundleString : string , pluginIdIfNotSpecified = '' ) : Promise < Plugin > {
2020-11-14 00:03:10 +02:00
baseDir = rtrimSlashes ( baseDir ) ;
2020-10-13 12:16:36 +02:00
const r = await this . parsePluginJsBundle ( jsBundleString ) ;
2020-11-17 20:26:24 +02:00
return this . loadPlugin ( baseDir , r . manifestText , r . scriptText , pluginIdIfNotSpecified ) ;
}
public async loadPluginFromPackage ( baseDir : string , path : string ) : Promise < Plugin > {
baseDir = rtrimSlashes ( baseDir ) ;
const fname = filename ( path ) ;
2021-01-27 19:42:58 +02:00
const hash = await shim . fsDriver ( ) . md5File ( path ) ;
2020-11-17 20:26:24 +02:00
2021-01-20 00:58:09 +02:00
const unpackDir = ` ${ Setting . value ( 'cacheDir' ) } / ${ fname } ` ;
2020-11-17 20:26:24 +02:00
const manifestFilePath = ` ${ unpackDir } /manifest.json ` ;
let manifest : any = await this . loadManifestToObject ( manifestFilePath ) ;
if ( ! manifest || manifest . _package_hash !== hash ) {
await shim . fsDriver ( ) . remove ( unpackDir ) ;
await shim . fsDriver ( ) . mkdir ( unpackDir ) ;
2021-01-27 19:42:58 +02:00
await shim . fsDriver ( ) . tarExtract ( {
2020-11-17 20:26:24 +02:00
strict : true ,
portable : true ,
file : path ,
cwd : unpackDir ,
} ) ;
manifest = await this . loadManifestToObject ( manifestFilePath ) ;
if ( ! manifest ) throw new Error ( ` Missing manifest file at: ${ manifestFilePath } ` ) ;
manifest . _package_hash = hash ;
2021-01-20 00:58:09 +02:00
await shim . fsDriver ( ) . writeFile ( manifestFilePath , JSON . stringify ( manifest , null , '\t' ) , 'utf8' ) ;
2020-11-17 20:26:24 +02:00
}
return this . loadPluginFromPath ( unpackDir ) ;
}
// Loads the manifest as a simple object with no validation. Used only
// when unpacking a package.
private async loadManifestToObject ( path : string ) : Promise < any > {
try {
const manifestText = await shim . fsDriver ( ) . readFile ( path , 'utf8' ) ;
return JSON . parse ( manifestText ) ;
} catch ( error ) {
return null ;
}
2020-10-13 12:16:36 +02:00
}
2020-11-19 14:34:49 +02:00
public async loadPluginFromPath ( path : string ) : Promise < Plugin > {
2020-11-14 00:03:10 +02:00
path = rtrimSlashes ( path ) ;
2020-10-09 19:35:46 +02:00
const fsDriver = shim . fsDriver ( ) ;
2020-11-17 20:26:24 +02:00
if ( path . toLowerCase ( ) . endsWith ( '.js' ) ) {
return this . loadPluginFromJsBundle ( dirname ( path ) , await fsDriver . readFile ( path ) , filename ( path ) ) ;
} else if ( path . toLowerCase ( ) . endsWith ( '.jpl' ) ) {
return this . loadPluginFromPackage ( dirname ( path ) , path ) ;
} else {
let distPath = path ;
if ( ! ( await fsDriver . exists ( ` ${ distPath } /manifest.json ` ) ) ) {
distPath = ` ${ path } /dist ` ;
}
2020-10-09 19:35:46 +02:00
2020-11-19 17:25:02 +02:00
logger . info ( ` Loading plugin from ${ path } ` ) ;
2020-10-09 19:35:46 +02:00
2020-11-17 20:26:24 +02:00
const scriptText = await fsDriver . readFile ( ` ${ distPath } /index.js ` ) ;
const manifestText = await fsDriver . readFile ( ` ${ distPath } /manifest.json ` ) ;
const pluginId = makePluginId ( filename ( path ) ) ;
2020-10-09 19:35:46 +02:00
2020-11-17 20:26:24 +02:00
return this . loadPlugin ( distPath , manifestText , scriptText , pluginId ) ;
}
2020-10-13 12:16:36 +02:00
}
2020-11-17 20:26:24 +02:00
private async loadPlugin ( baseDir : string , manifestText : string , scriptText : string , pluginIdIfNotSpecified : string ) : Promise < Plugin > {
2020-11-14 00:03:10 +02:00
baseDir = rtrimSlashes ( baseDir ) ;
2020-11-15 16:18:46 +02:00
const manifestObj = JSON . parse ( manifestText ) ;
2021-08-05 13:02:03 +02:00
interface DeprecationNotice {
goneInVersion : string ;
message : string ;
isError : boolean ;
}
const deprecationNotices : DeprecationNotice [ ] = [ ] ;
2020-11-15 16:18:46 +02:00
if ( ! manifestObj . app_min_version ) {
manifestObj . app_min_version = '1.4' ;
2021-08-05 13:02:03 +02:00
deprecationNotices . push ( {
message : 'The manifest must contain an "app_min_version" key, which should be the minimum version of the app you support.' ,
goneInVersion : '1.4' ,
isError : true ,
} ) ;
2020-11-17 20:26:24 +02:00
}
if ( ! manifestObj . id ) {
manifestObj . id = pluginIdIfNotSpecified ;
2021-08-05 13:02:03 +02:00
deprecationNotices . push ( {
message : 'The manifest must contain an "id" key, which should be a globally unique ID for your plugin, such as "com.example.MyPlugin" or a UUID.' ,
goneInVersion : '1.4' ,
isError : true ,
} ) ;
2020-11-15 16:18:46 +02:00
}
const manifest = manifestFromObject ( manifestObj ) ;
2020-10-09 19:35:46 +02:00
2021-01-24 17:51:35 +02:00
const dataDir = ` ${ Setting . value ( 'pluginDataDir' ) } / ${ manifest . id } ` ;
const plugin = new Plugin ( baseDir , manifest , scriptText , ( action : any ) = > this . store_ . dispatch ( action ) , dataDir ) ;
2020-11-15 16:18:46 +02:00
2021-08-05 13:02:03 +02:00
for ( const notice of deprecationNotices ) {
plugin . deprecationNotice ( notice . goneInVersion , notice . message , notice . isError ) ;
2020-11-15 16:18:46 +02:00
}
2020-10-09 19:35:46 +02:00
2020-11-20 01:46:04 +02:00
// Sanity check, although at that point the plugin ID should have
// been set, either automatically, or because it was defined in the
// manifest.
if ( ! plugin . id ) throw new Error ( 'Could not load plugin: ID is not set' ) ;
2020-10-09 19:35:46 +02:00
return plugin ;
}
2020-11-19 14:34:49 +02:00
private pluginEnabled ( settings : PluginSettings , pluginId : string ) : boolean {
if ( ! settings [ pluginId ] ) return true ;
return settings [ pluginId ] . enabled !== false ;
}
2022-04-19 16:52:32 +02:00
public callStatsSummary ( pluginId : string , duration : number ) {
return this . runner_ . callStatsSummary ( pluginId , duration ) ;
}
2023-12-22 13:31:57 +02:00
public async loadAndRunPlugins (
pluginDirOrPaths : string | string [ ] , settings : PluginSettings , options? : PluginLoadOptions ,
) {
options ? ? = {
builtIn : false ,
devMode : false ,
} ;
2020-10-09 19:35:46 +02:00
let pluginPaths = [ ] ;
if ( Array . isArray ( pluginDirOrPaths ) ) {
pluginPaths = pluginDirOrPaths ;
} else {
pluginPaths = ( await shim . fsDriver ( ) . readDirStats ( pluginDirOrPaths ) )
2020-11-19 14:34:49 +02:00
. filter ( ( stat : any ) = > {
if ( stat . isDirectory ( ) ) return true ;
if ( stat . path . toLowerCase ( ) . endsWith ( '.js' ) ) return true ;
if ( stat . path . toLowerCase ( ) . endsWith ( '.jpl' ) ) return true ;
return false ;
} )
2020-11-12 21:13:28 +02:00
. map ( ( stat : any ) = > ` ${ pluginDirOrPaths } / ${ stat . path } ` ) ;
2020-10-09 19:35:46 +02:00
}
for ( const pluginPath of pluginPaths ) {
2020-12-10 18:45:00 +02:00
if ( filename ( pluginPath ) . indexOf ( '_' ) === 0 ) {
2020-11-19 17:25:02 +02:00
logger . info ( ` Plugin name starts with "_" and has not been loaded: ${ pluginPath } ` ) ;
2020-10-09 19:35:46 +02:00
continue ;
}
try {
2020-10-13 12:16:36 +02:00
const plugin = await this . loadPluginFromPath ( pluginPath ) ;
2020-11-19 14:34:49 +02:00
// After transforming the plugin path to an ID, multiple plugins might end up with the same ID. For
// example "MyPlugin" and "myplugin" would have the same ID. Technically it's possible to have two
// such folders but to keep things sane we disallow it.
if ( this . plugins_ [ plugin . id ] ) throw new Error ( ` There is already a plugin with this ID: ${ plugin . id } ` ) ;
2023-12-22 13:31:57 +02:00
// We mark the plugin as built-in even if not enabled (being built-in affects
// update UI).
plugin . builtIn = options . builtIn ;
2020-11-19 14:34:49 +02:00
this . setPluginAt ( plugin . id , plugin ) ;
if ( ! this . pluginEnabled ( settings , plugin . id ) ) {
2020-11-19 17:25:02 +02:00
logger . info ( ` Not running disabled plugin: " ${ plugin . id } " ` ) ;
2020-11-19 14:34:49 +02:00
continue ;
}
2023-12-22 13:31:57 +02:00
plugin . devMode = options . devMode ;
2020-11-19 14:34:49 +02:00
2020-10-09 19:35:46 +02:00
await this . runPlugin ( plugin ) ;
} catch ( error ) {
2020-11-19 17:25:02 +02:00
logger . error ( ` Could not load plugin: ${ pluginPath } ` , error ) ;
2020-10-09 19:35:46 +02:00
}
}
}
2021-01-24 20:45:42 +02:00
public isCompatible ( pluginVersion : string ) : boolean {
return compareVersions ( this . appVersion_ , pluginVersion ) >= 0 ;
}
2021-02-07 18:47:56 +02:00
public get allPluginsStarted ( ) : boolean {
for ( const pluginId of Object . keys ( this . startedPlugins_ ) ) {
if ( ! this . startedPlugins_ [ pluginId ] ) return false ;
}
return true ;
}
2020-11-12 21:13:28 +02:00
public async runPlugin ( plugin : Plugin ) {
2021-04-24 20:23:33 +02:00
if ( this . isSafeMode ) throw new Error ( ` Plugin was not started due to safe mode: ${ plugin . manifest . id } ` ) ;
2021-01-24 20:45:42 +02:00
if ( ! this . isCompatible ( plugin . manifest . app_min_version ) ) {
2020-11-19 17:25:02 +02:00
throw new Error ( ` Plugin " ${ plugin . id } " was disabled because it requires Joplin version ${ plugin . manifest . app_min_version } and current version is ${ this . appVersion_ } . ` ) ;
2020-11-19 14:34:49 +02:00
} else {
this . store_ . dispatch ( {
type : 'PLUGIN_ADD' ,
plugin : {
id : plugin.id ,
views : { } ,
contentScripts : { } ,
} ,
} ) ;
}
2021-02-07 18:47:56 +02:00
this . startedPlugins_ [ plugin . id ] = false ;
const onStarted = ( ) = > {
this . startedPlugins_ [ plugin . id ] = true ;
plugin . off ( 'started' , onStarted ) ;
} ;
plugin . on ( 'started' , onStarted ) ;
2020-11-19 17:25:02 +02:00
const pluginApi = new Global ( this . platformImplementation_ , plugin , this . store_ ) ;
2020-10-09 19:35:46 +02:00
return this . runner_ . run ( plugin , pluginApi ) ;
}
2021-01-07 18:30:53 +02:00
public async installPluginFromRepo ( repoApi : RepositoryApi , pluginId : string ) : Promise < Plugin > {
const pluginPath = await repoApi . downloadPlugin ( pluginId ) ;
const plugin = await this . installPlugin ( pluginPath ) ;
await shim . fsDriver ( ) . remove ( pluginPath ) ;
return plugin ;
}
2021-01-20 00:58:09 +02:00
public async updatePluginFromRepo ( repoApi : RepositoryApi , pluginId : string ) : Promise < Plugin > {
return this . installPluginFromRepo ( repoApi , pluginId ) ;
}
2023-06-30 10:11:26 +02:00
public async installPlugin ( jplPath : string , loadPlugin = true ) : Promise < Plugin | null > {
2020-11-19 17:25:02 +02:00
logger . info ( ` Installing plugin: " ${ jplPath } " ` ) ;
2020-11-19 14:34:49 +02:00
2020-11-20 01:46:04 +02:00
// Before moving the plugin to the profile directory, we load it
// from where it is now to check that it is valid and to retrieve
// the plugin ID.
const preloadedPlugin = await this . loadPluginFromPath ( jplPath ) ;
2021-01-20 00:58:09 +02:00
await this . deletePluginFiles ( preloadedPlugin ) ;
2020-11-20 01:46:04 +02:00
const destPath = ` ${ Setting . value ( 'pluginDir' ) } / ${ preloadedPlugin . id } .jpl ` ;
2020-11-19 14:34:49 +02:00
await shim . fsDriver ( ) . copy ( jplPath , destPath ) ;
2020-11-20 01:46:04 +02:00
// Now load it from the profile directory
2022-09-01 12:44:33 +02:00
if ( loadPlugin ) {
const plugin = await this . loadPluginFromPath ( destPath ) ;
if ( ! this . plugins_ [ plugin . id ] ) this . setPluginAt ( plugin . id , plugin ) ;
return plugin ;
} else { return null ; }
2020-11-19 14:34:49 +02:00
}
2020-11-13 19:09:28 +02:00
2020-11-19 14:34:49 +02:00
private async pluginPath ( pluginId : string ) {
const stats = await shim . fsDriver ( ) . readDirStats ( Setting . value ( 'pluginDir' ) , { recursive : false } ) ;
2020-11-13 19:09:28 +02:00
2020-11-19 14:34:49 +02:00
for ( const stat of stats ) {
if ( filename ( stat . path ) === pluginId ) {
return ` ${ Setting . value ( 'pluginDir' ) } / ${ stat . path } ` ;
}
}
return null ;
}
public async uninstallPlugin ( pluginId : string ) {
2020-11-19 17:25:02 +02:00
logger . info ( ` Uninstalling plugin: " ${ pluginId } " ` ) ;
2020-11-19 14:34:49 +02:00
const path = await this . pluginPath ( pluginId ) ;
if ( ! path ) {
// Plugin might have already been deleted
2020-11-19 17:25:02 +02:00
logger . error ( ` Could not find plugin path to uninstall - nothing will be done: ${ pluginId } ` ) ;
2020-11-19 14:34:49 +02:00
} else {
await shim . fsDriver ( ) . remove ( path ) ;
}
this . deletePluginAt ( pluginId ) ;
}
public async uninstallPlugins ( settings : PluginSettings ) : Promise < PluginSettings > {
let newSettings = settings ;
for ( const pluginId in settings ) {
if ( settings [ pluginId ] . deleted ) {
await this . uninstallPlugin ( pluginId ) ;
2024-01-18 13:24:44 +02:00
newSettings = { . . . newSettings } ;
2020-11-19 14:34:49 +02:00
delete newSettings [ pluginId ] ;
}
}
return newSettings ;
}
2020-11-13 19:09:28 +02:00
2021-01-20 00:58:09 +02:00
// On startup the "hasBeenUpdated" prop can be cleared since the new version
// of the plugin has now been loaded.
public clearUpdateState ( settings : PluginSettings ) : PluginSettings {
return produce ( settings , ( draft : PluginSettings ) = > {
for ( const pluginId in draft ) {
if ( draft [ pluginId ] . hasBeenUpdated ) draft [ pluginId ] . hasBeenUpdated = false ;
}
} ) ;
}
2020-12-01 16:08:41 +02:00
public async destroy() {
await this . runner_ . waitForSandboxCalls ( ) ;
}
2020-10-09 19:35:46 +02:00
}