Merge pull request #29 from StarFleetCPTN/development

New Logo, UI Updates, and Bug Fixes
This commit is contained in:
StarFleetCPTN
2025-03-16 16:14:25 -07:00
committed by GitHub
35 changed files with 1343 additions and 731 deletions
+43 -33
View File
@@ -1,35 +1,38 @@
# GoMFT - Go Managed File Transfer
<p align="center">
<img src="static/img/logo.svg" alt="GoMFT Logo" width="200">
</p>
<h1 align="center">GoMFT - Go Managed File Transfer</h1>
GoMFT is a web-based managed file transfer application built with Go, leveraging rclone for robust file transfer capabilities. It provides a user-friendly interface for configuring, scheduling, and monitoring file transfers across various storage providers.
<div style="background-color: #fff3e0; border-left: 4px solid #ff9800; padding: 1rem; margin: 1rem 0;">
<p style="margin: 0; display: flex; align-items: center; color: #e65100;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" style="margin-right: 0.5rem; fill: currentColor;">
<path d="M12 2L1 21h22M12 6l7.53 13H4.47M11 10v4h2v-4m-2 6v2h2v-2" />
</svg>
<strong>WARNING:</strong>&nbsp;GoMFT is in active development and configuration may change often. Please make sure to carefully read the release notes before updating.
</p>
</div>
## Screenshots
### Dashboard
![Dashboard Overview](screenshots/dashboard.gomft.png)
*Dashboard showing active transfers and system status*
![Dashboard Overview Dark](screenshots/dashboard.dark.gomft.png)
*Dashboard dark mode showing active transfers and system status*
### Configuration Interface
![Transfer Configuration](screenshots/new.configuration.gomft.png)
*Setting up transfer configurations with multiple storage options*
### Job Management
![Job Scheduling](screenshots/new.job.gomft.png)
*Scheduling transfers with flexible cron expressions*
### File Metadata
![File Metadata](screenshots/file.metadata.gomft.png)
*Track and manage file metadata with detailed information about transferred files*
### User Management
![User Management](screenshots/user.management.gomft.png)
*Create user accounts and manage them*
### Admin Tools
![Admin Tools](screenshots/admin.tools.gomft.png)
*Admin dashboard with log viewer and system management tools*
<table>
<tr>
<td width="33%">
<a href="screenshots/dashboard.gomft.png">
<img src="screenshots/dashboard.gomft.png" alt="Dashboard Overview" width="100%">
</a>
<p align="center"><em>Dashboard showing active transfers</em></p>
</td>
<td width="33%">
<a href="screenshots/dashboard.dark.gomft.png">
<img src="screenshots/dashboard.dark.gomft.png" alt="Dashboard Dark Mode" width="100%">
</a>
<p align="center"><em>Dashboard dark mode</em></p>
</td>
</tr>
</table>
## Features
@@ -345,8 +348,8 @@ User management features:
### Transfer Configuration Options
1. **Source/Destination Types**:
- Google Drive (with built-in or custom authentication)
- Google Photos (with built-in or custom authentication)
- Google Drive
- Google Photos
- Local filesystem
- Amazon S3
- MinIO (S3-compatible storage)
@@ -562,10 +565,6 @@ air
4. Push to the branch
5. Create a Pull Request
## License
MIT License - see LICENSE file for details
## Directory Structure
GoMFT uses the following directory structure:
@@ -585,3 +584,14 @@ volumes:
```
These paths can be customized using the environment variables `DATA_DIR`, `BACKUP_DIR`, and `LOGS_DIR`.
## License
[MIT License](LICENSE) - see the full license terms
The GoMFT logo is licensed under the Creative Commons Attribution 4.0 International Public License.
The gopher design is from https://github.com/egonelbre/gophers.
The original Go gopher was designed by Renee French (http://reneefrench.blogspot.com/).
+51 -21
View File
@@ -84,7 +84,8 @@ func getInitialData(config *db.TransferConfig) string {
skipProcessedFiles := true
maxConcurrentTransfers := 4
rcloneFlags := ""
useBuiltinAuth := true
useBuiltinAuthSource := true
useBuiltinAuthDest := true
// If editing an existing config, populate with those values
if config != nil {
@@ -162,14 +163,19 @@ func getInitialData(config *db.TransferConfig) string {
skipProcessedFiles = config.GetSkipProcessedFiles()
maxConcurrentTransfers = config.MaxConcurrentTransfers
rcloneFlags = config.RcloneFlags
if config.UseBuiltinAuth != nil {
useBuiltinAuth = *config.UseBuiltinAuth
if config.UseBuiltinAuthSource != nil {
useBuiltinAuthSource = *config.UseBuiltinAuthSource
} else if sourceClientId != "" || sourceClientSecret != "" {
useBuiltinAuthSource = false
}
if config.UseBuiltinAuthDest != nil {
useBuiltinAuthDest = *config.UseBuiltinAuthDest
} else if destClientId != "" || destClientSecret != "" {
useBuiltinAuth = false
useBuiltinAuthDest = false
}
}
// Return the JSON-formatted string with all the data
// Return the JSON-formatted string with all the data, add new path validation states
return fmt.Sprintf(`{
name: '%s',
sourceType: '%s',
@@ -223,7 +229,8 @@ func getInitialData(config *db.TransferConfig) string {
destStartYear: %d,
destIncludeArchived: %v,
useBuiltinAuth: %v,
useBuiltinAuthSource: %v,
useBuiltinAuthDest: %v,
archivePath: '%s',
archiveEnabled: %v,
@@ -231,6 +238,32 @@ func getInitialData(config *db.TransferConfig) string {
skipProcessedFiles: %v,
maxConcurrentTransfers: %d,
rcloneFlags: '%s',
// Path validation states
sourcePathValid: null,
sourcePathError: '',
destPathValid: null,
destPathError: '',
// Methods for path validation
checkPath(path, type) {
if (!path) {
this[type + 'PathValid'] = false;
this[type + 'PathError'] = 'Path cannot be empty';
return;
}
fetch('/check-path?path=' + encodeURIComponent(path))
.then(response => response.json())
.then(data => {
this[type + 'PathValid'] = data.valid;
this[type + 'PathError'] = data.error || '';
})
.catch(error => {
this[type + 'PathValid'] = false;
this[type + 'PathError'] = 'Error checking path: ' + error.message;
});
}
}`,
name, sourceType, sourcePath, sourceHost, sourcePort, sourceUser, sourcePassword, sourceKeyFile, sourceAuthType,
sourceBucket, sourceRegion, sourceAccessKey, sourceSecretKey, sourceEndpoint, sourceShare, sourceDomain, sourcePassiveMode,
@@ -241,24 +274,24 @@ func getInitialData(config *db.TransferConfig) string {
destBucket, destRegion, destAccessKey, destSecretKey, destEndpoint, destShare, destDomain, destPassiveMode,
destClientId, destClientSecret, destDriveId, destTeamDrive,
destReadOnly, destStartYear, destIncludeArchived,
useBuiltinAuth,
useBuiltinAuthSource, useBuiltinAuthDest,
archivePath, archiveEnabled, deleteAfterTransfer, skipProcessedFiles, maxConcurrentTransfers, rcloneFlags)
}
templ ConfigForm(ctx context.Context, data ConfigFormData) {
@LayoutWithContext(getConfigFormTitle(data.IsNew), ctx) {
<div class="flex flex-col items-center justify-center min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-3xl w-full bg-white rounded-lg shadow-md p-8">
<div class="flex flex-col items-center justify-center min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-3xl w-full bg-white dark:bg-secondary-800 rounded-lg shadow-md p-8 border border-secondary-200 dark:border-secondary-700">
<div class="flex justify-center mb-6">
<div class="p-4 bg-blue-100 rounded-full">
<i class="fas fa-cog text-blue-500 text-2xl"></i>
<div class="p-4 bg-primary-100 dark:bg-primary-900 rounded-full">
<i class="fas fa-cog text-primary-500 dark:text-primary-400 text-2xl"></i>
</div>
</div>
<h1 class="mt-2 text-center text-3xl font-extrabold text-gray-900">
<h1 class="mt-2 text-center text-3xl font-extrabold text-secondary-900 dark:text-secondary-100">
{ getConfigFormTitle(data.IsNew) }
</h1>
<p class="mt-2 text-center text-sm text-gray-600">
<p class="mt-2 text-center text-sm text-secondary-600 dark:text-secondary-400">
Set up your file transfer configuration
</p>
@@ -321,7 +354,7 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
@source.NextCloudSourceForm()
</template>
<template x-if="sourceType === 'google_drive'">
<template x-if="sourceType === 'gdrive'">
@source.GoogleDriveSourceForm()
</template>
@@ -385,17 +418,14 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
<!-- Form actions -->
<div class="flex justify-end mt-8">
<a href="/configs"
class="btn-secondary mr-4 px-6 py-3 rounded-lg flex items-center0">
class="btn-secondary mr-4">
Cancel
</a>
<button
type="submit"
class="btn-primary px-6 py-3 rounded-lg flex items-center"
>
class="btn-primary">
<span class="flex items-center">
<template>
<i class="fas fa-save mr-2"></i>
</template>
<i class="fas fa-save mr-2"></i>
if data.IsNew {
Create Configuration
} else {
@@ -407,7 +437,7 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
</form>
</div>
<div class="mt-6 text-center text-xs text-gray-500">
<div class="mt-6 text-center text-xs text-secondary-500 dark:text-secondary-400">
<p>Configure your file transfer settings carefully for optimal performance</p>
</div>
</div>
+3 -3
View File
@@ -289,7 +289,7 @@ templ Configs(ctx context.Context, data ConfigsData) {
<!-- Google Drive Authentication Button -->
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d/gdrive-auth", config.ID)) } class="btn-warning btn-sm">
<i class="fab fa-google-drive mr-1"></i>
<i class="fab fa-google mr-1"></i>
Authenticate
</a>
}
@@ -356,8 +356,8 @@ templ Configs(ctx context.Context, data ConfigsData) {
<!-- Google Drive Auth Help -->
<div class="mt-4 text-center">
<p class="text-sm text-secondary-500 dark:text-secondary-400">
<i class="fab fa-google-drive mr-1 text-blue-500 inline-flex items-center"></i>
Google Drive configurations require authentication. Click the "Authenticate" button to complete setup.
<i class="fab fa-google mr-1 text-blue-500 inline-flex items-center"></i>
Google Drive and Google Photos configurations require authentication. Click the "Authenticate" button to complete setup.
</p>
</div>
</div>
+2 -2
View File
@@ -95,8 +95,8 @@ templ Home(ctx context.Context) {
</div>
} else {
<div class="max-w-md mx-auto card p-8 hover:shadow-lg transition-shadow duration-300">
<div class="rounded-full bg-primary-100 dark:bg-primary-900 p-4 w-20 h-20 flex items-center justify-center mb-6 mx-auto">
<i class="fas fa-sign-in-alt text-primary-600 dark:text-primary-300 text-3xl"></i>
<div class="inline-flex items-center justify-center w-64 h-64 mb-4">
<img src="/static/img/logo.svg" alt="GoMFT Logo" class="w-full h-full"/>
</div>
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">Get Started</h2>
<p class="text-secondary-600 dark:text-secondary-400 mb-6">Log in to access the file transfer management system.</p>
+26 -26
View File
@@ -49,7 +49,7 @@ templ FilePatternFields() {
Pattern for output filename. Use variables like ${`filename`}, ${`timestamp`}, ${`date`}
</p>
<div>
<p class="mt-1 text-xs text-gray-500">
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Pattern for filenames. Available variables:<br />
${`filename`} - Original filename without extension (e.g., "report")<br />
${`ext`} - Original file extension (e.g., "csv")<br />
@@ -65,8 +65,8 @@ templ FilePatternFields() {
}
templ ArchiveOptions() {
<div class="border border-gray-200 rounded-lg p-6 bg-gray-50 shadow-sm">
<h3 class="text-lg font-medium text-gray-900 mb-4">Archive &amp; Delete Options</h3>
<div class="border border-secondary-200 dark:border-secondary-700 rounded-lg p-6 bg-secondary-50 dark:bg-secondary-800/50 shadow-sm">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">Archive &amp; Delete Options</h3>
<div class="mb-4">
<label for="archive_enabled" class="flex items-center cursor-pointer">
@@ -75,29 +75,29 @@ templ ArchiveOptions() {
class="sr-only"
:value="archiveEnabled ? 'true' : 'false'"
/>
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="archiveEnabled ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Enable archiving
</div>
</label>
</div>
<div class="mb-4" x-show="archiveEnabled">
<label for="archive_path" class="block text-sm font-medium text-gray-700 mb-1">Archive Path</label>
<label for="archive_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Archive Path</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-gray-400"></i>
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
</span>
<input id="archive_path" name="archive_path" type="text"
class="pl-10 pr-10 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Path to archive files"
x-model="archivePath"
/>
</div>
<p class="mt-1 text-xs text-gray-500">
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Files will be moved here after successful transfer
</p>
</div>
@@ -109,15 +109,15 @@ templ ArchiveOptions() {
class="sr-only"
:value="deleteAfterTransfer ? 'true' : 'false'"
/>
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="deleteAfterTransfer ? 'transform translate-x-6 bg-red-500' : ''"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Delete source files after transfer
</div>
</label>
<p class="mt-1 ml-14 text-xs text-red-500" x-show="deleteAfterTransfer">
<p class="mt-1 ml-14 text-xs text-red-500 dark:text-red-400" x-show="deleteAfterTransfer">
<i class="fas fa-exclamation-triangle mr-1"></i> Warning: This will permanently delete the original files
</p>
</div>
@@ -129,30 +129,30 @@ templ ArchiveOptions() {
class="sr-only"
:value="skipProcessedFiles ? 'true' : 'false'"
/>
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="skipProcessedFiles ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Skip files that have already been processed
</div>
</label>
<p class="mt-1 ml-14 text-xs text-gray-500">
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
Files with the same hash that have been successfully processed before will be skipped
</p>
</div>
<div class="mb-4">
<label for="max_concurrent_transfers" class="block text-sm font-medium text-gray-700 mb-1">Concurrent Transfers</label>
<label for="max_concurrent_transfers" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Concurrent Transfers</label>
<div class="flex items-center">
<input type="range" id="max_concurrent_transfers" name="max_concurrent_transfers"
class="form-range w-2/3 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
class="form-range w-2/3 h-2 bg-secondary-200 dark:bg-secondary-700 rounded-lg appearance-none cursor-pointer"
min="1" max="32" step="1"
x-model="maxConcurrentTransfers"
/>
<span class="ml-4 text-gray-700" x-text="maxConcurrentTransfers"></span>
<span class="ml-4 text-secondary-700 dark:text-secondary-300" x-text="maxConcurrentTransfers"></span>
</div>
<p class="mt-1 text-xs text-gray-500">
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Number of files to transfer simultaneously (higher values may improve performance but increase resource usage)
</p>
</div>
@@ -161,16 +161,16 @@ templ ArchiveOptions() {
templ RcloneFlags() {
<div>
<label for="rclone_flags" class="block text-sm font-medium text-gray-700">Rclone Flags</label>
<label for="rclone_flags" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300">Rclone Flags</label>
<div class="mt-1 relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-flag text-gray-400"></i>
<i class="fas fa-flag text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="text" name="rclone_flags" id="rclone_flags" x-model="rcloneFlags"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="--transfers 4 --checkers 8" />
</div>
<p class="mt-1 text-xs text-gray-500">
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Optional: Additional rclone flags for fine-tuning the transfer.
</p>
</div>
@@ -194,7 +194,7 @@ templ SourceSelection() {
<option value="smb">SMB</option>
<option value="nextcloud">NextCloud</option>
<option value="webdav">WebDAV</option>
<option value="google_drive">Google Drive</option>
<option value="gdrive">Google Drive</option>
<option value="gphotos">Google Photos</option>
</select>
</div>
+35 -38
View File
@@ -1,24 +1,25 @@
package destination
templ GoogleDriveDestinationForm() {
<div class="space-y-6" x-init="$watch('useBuiltinAuth', value => {
<div class="space-y-6" x-init="$watch('useBuiltinAuthDest', value => {
if(value) {
destClientId = '';
destClientSecret = '';
}
})">
<div class="mb-6">
<label for="use_builtin_auth" class="flex items-center cursor-pointer">
<label for="use_builtin_auth_dest" class="flex items-center cursor-pointer">
<div class="relative">
<input id="use_builtin_auth" name="use_builtin_auth" type="checkbox"
<input id="use_builtin_auth_dest" name="use_builtin_auth_dest" type="checkbox"
class="sr-only"
x-model="useBuiltinAuth"
x-model="useBuiltinAuthDest"
:value="useBuiltinAuthDest ? 'true' : 'false'"
/>
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
:class="useBuiltinAuth ? 'transform translate-x-6 bg-primary-500' : ''"></div>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="useBuiltinAuthDest ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Use rclone's built-in Google authentication (recommended)
</div>
</label>
@@ -27,11 +28,11 @@ templ GoogleDriveDestinationForm() {
</p>
</div>
<div x-bind:class="{ 'opacity-50': useBuiltinAuth }">
<div x-bind:class="{ 'opacity-50': useBuiltinAuthDest }">
<div>
<label for="dest_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client ID
<span x-show="useBuiltinAuth" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@@ -39,7 +40,7 @@ templ GoogleDriveDestinationForm() {
</div>
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuth"
x-bind:disabled="useBuiltinAuthDest"
placeholder="Google Drive OAuth Client ID" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
@@ -50,7 +51,7 @@ templ GoogleDriveDestinationForm() {
<div class="mt-4">
<label for="dest_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client Secret
<span x-show="useBuiltinAuth" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@@ -58,7 +59,7 @@ templ GoogleDriveDestinationForm() {
</div>
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuth"
x-bind:disabled="useBuiltinAuthDest"
placeholder="Google Drive OAuth Client Secret" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
@@ -112,61 +113,57 @@ templ GoogleDriveDestinationForm() {
</p>
</div>
<div class="p-4 bg-amber-50 rounded-lg border border-amber-100">
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-amber-500"></i>
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800">Important: Authentication Required</h3>
<div class="mt-2 text-sm text-amber-700">
<p><strong>After saving this configuration</strong>, you will need to authenticate with Google Drive.</p>
<p class="mt-1">The authentication process will require you to:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
<li>Visit a Google authorization URL</li>
<li>Sign in to your Google account</li>
<li>Grant permission to access your Google Drive</li>
<li>Copy the authorization code back to this application</li>
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>After saving this configuration, you'll need to authenticate with Google Drive:</p>
<ol class="list-decimal list-inside mt-2 space-y-1">
<li>Go to the configurations page</li>
<li>Click the "Authenticate" button for this configuration</li>
<li>You'll be redirected to Google to grant access permissions</li>
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
</ol>
<p class="mt-2 text-xs">
This is a one-time process for each configuration. The application will store your authorization token securely.
</p>
</div>
</div>
</div>
</div>
<div class="p-4 bg-blue-50 rounded-lg border border-blue-100">
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400"></i>
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700">
<template x-if="useBuiltinAuth">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
<template x-if="useBuiltinAuthDest">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>No need to create your own Google API credentials</li>
<li>Authentication happens through a browser window</li>
<li>You will need to manually copy the authorization code back</li>
<li>Authentication happens through Google's secure OAuth flow</li>
<li>Automatic redirection back to GoMFT after authentication</li>
</ul>
<p class="mt-2 text-xs text-amber-600">
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
<i class="fas fa-exclamation-triangle mr-1"></i>
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
</p>
</div>
</template>
<template x-if="!useBuiltinAuth">
<template x-if="!useBuiltinAuthDest">
<div>
<p>To use Google Drive with your own credentials:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 underline">Google Cloud Console</a></li>
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
<li>Create a project and enable the Google Drive API</li>
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
<li>Set authorized redirect URI to <code class="bg-blue-100 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
</ol>
</div>
</template>
+47 -53
View File
@@ -1,25 +1,25 @@
package destination
templ GooglePhotosDestinationForm() {
<div class="space-y-6" x-init="$watch('useBuiltinAuth', value => {
<div class="space-y-6" x-init="$watch('useBuiltinAuthDest', value => {
if(value) {
destClientId = '';
destClientSecret = '';
}
})">
<div class="mb-6">
<label for="use_builtin_auth" class="flex items-center cursor-pointer">
<label for="use_builtin_auth_dest" class="flex items-center cursor-pointer">
<div class="relative">
<input id="use_builtin_auth" name="use_builtin_auth" type="checkbox"
<input id="use_builtin_auth_dest" name="use_builtin_auth_dest" type="checkbox"
class="sr-only"
x-model="useBuiltinAuth"
value="true"
x-model="useBuiltinAuthDest"
:value="useBuiltinAuthDest ? 'true' : 'false'"
/>
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
:class="useBuiltinAuth ? 'transform translate-x-6 bg-primary-500' : ''"></div>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="useBuiltinAuthDest ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Use rclone's built-in Google authentication (recommended)
</div>
</label>
@@ -28,11 +28,11 @@ templ GooglePhotosDestinationForm() {
</p>
</div>
<div x-bind:class="{ 'opacity-50': useBuiltinAuth }">
<div x-bind:class="{ 'opacity-50': useBuiltinAuthDest }">
<div>
<label for="dest_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client ID
<span x-show="useBuiltinAuth" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@@ -40,7 +40,7 @@ templ GooglePhotosDestinationForm() {
</div>
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuth"
x-bind:disabled="useBuiltinAuthDest"
placeholder="Google Photos OAuth Client ID" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
@@ -51,7 +51,7 @@ templ GooglePhotosDestinationForm() {
<div class="mt-4">
<label for="dest_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client Secret
<span x-show="useBuiltinAuth" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@@ -59,7 +59,7 @@ templ GooglePhotosDestinationForm() {
</div>
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuth"
x-bind:disabled="useBuiltinAuthDest"
placeholder="Google Photos OAuth Client Secret" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
@@ -74,13 +74,12 @@ templ GooglePhotosDestinationForm() {
<input id="dest_read_only" name="dest_read_only" type="checkbox"
class="sr-only"
x-model="destReadOnly"
value="true"
/>
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="destReadOnly ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Read-only mode
</div>
</label>
@@ -110,13 +109,12 @@ templ GooglePhotosDestinationForm() {
<input id="dest_include_archived" name="dest_include_archived" type="checkbox"
class="sr-only"
x-model="destIncludeArchived"
value="true"
/>
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="destIncludeArchived ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Include archived media
</div>
</label>
@@ -133,68 +131,64 @@ templ GooglePhotosDestinationForm() {
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Album path (e.g., /album/my-photos)" />
placeholder="Album path (e.g., album/my-photos)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Path within Google Photos where files will be uploaded
Path within Google Photos where files will be uploaded.
</p>
</div>
<div class="p-4 bg-amber-50 rounded-lg border border-amber-100">
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-amber-500"></i>
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800">Important: Authentication Required</h3>
<div class="mt-2 text-sm text-amber-700">
<p><strong>After saving this configuration</strong>, you will need to authenticate with Google Photos.</p>
<p class="mt-1">The authentication process will require you to:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
<li>Visit a Google authorization URL</li>
<li>Sign in to your Google account</li>
<li>Grant permission to access your Google Photos</li>
<li>Copy the authorization code back to this application</li>
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>After saving this configuration, you'll need to authenticate with Google Photos:</p>
<ol class="list-decimal list-inside mt-2 space-y-1">
<li>Go to the configurations page</li>
<li>Click the "Authenticate" button for this configuration</li>
<li>You'll be redirected to Google to grant access permissions</li>
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
</ol>
<p class="mt-2 text-xs">
This is a one-time process for each configuration. The application will store your authorization token securely.
</p>
</div>
</div>
</div>
</div>
<div class="p-4 bg-blue-50 rounded-lg border border-blue-100">
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400"></i>
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700">
<template x-if="useBuiltinAuth">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
<template x-if="useBuiltinAuthDest">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>No need to create your own Google API credentials</li>
<li>Authentication happens through a browser window</li>
<li>You will need to manually copy the authorization code back</li>
<li>Authentication happens through Google's secure OAuth flow</li>
<li>Automatic redirection back to GoMFT after authentication</li>
</ul>
<p class="mt-2 text-xs text-amber-600">
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
<i class="fas fa-exclamation-triangle mr-1"></i>
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
</p>
</div>
</template>
<template x-if="!useBuiltinAuth">
<template x-if="!useBuiltinAuthDest">
<div>
<p>To use Google Photos with your own credentials:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 underline">Google Cloud Console</a></li>
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
<li>Create a project and enable the Google Photos API</li>
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
<li>Set authorized redirect URI to <code class="bg-blue-100 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
</ol>
</div>
</template>
@@ -203,14 +197,14 @@ templ GooglePhotosDestinationForm() {
</div>
</div>
<div class="p-4 bg-yellow-50 rounded-lg border border-yellow-100">
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/50 rounded-lg border border-yellow-100 dark:border-yellow-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-circle text-yellow-500"></i>
<i class="fas fa-exclamation-circle text-yellow-500 dark:text-yellow-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">Important Note About Google Photos</h3>
<div class="mt-2 text-sm text-yellow-700">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">Important Note About Google Photos</h3>
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<p>All media items uploaded to Google Photos with rclone are stored in full resolution at original quality. These uploads will count towards storage in your Google Account.</p>
</div>
</div>
+19 -6
View File
@@ -6,7 +6,7 @@ templ LocalDestinationForm() {
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Local Path</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
@@ -14,13 +14,26 @@ templ LocalDestinationForm() {
id="destination_path"
x-model="destinationPath"
required
aria-describedby="destination_path_help"
:class="{ 'border-red-300 dark:border-red-700': destPathValid === false, 'border-green-300 dark:border-green-700': destPathValid === true }"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="/path/to/destination"/>
placeholder="Local directory path (e.g., /path/to/destination)"
/>
</div>
<p id="destination_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Absolute path to the local directory where files will be saved.
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Full path to the local directory where files will be saved
</p>
<!-- Path validation message -->
<template x-if="destPathValid !== null">
<p x-show="destPathError" class="mt-1 text-sm" :class="destPathValid ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'" x-text="destPathError"></p>
</template>
</div>
<button
type="button"
class="btn-secondary h-10 whitespace-nowrap"
@click="checkPath(destinationPath, 'dest')"
>
<i class="fas fa-check-circle mr-2"></i>
Check Location
</button>
</div>
}
}
+62 -16
View File
@@ -1,7 +1,7 @@
package source
templ GoogleDriveSourceForm() {
<div class="space-y-6" x-init="$watch('useBuiltinAuth', value => {
<div class="space-y-6" x-init="$watch('useBuiltinAuthSource', value => {
if(value) {
sourceClientId = '';
sourceClientSecret = '';
@@ -12,13 +12,14 @@ templ GoogleDriveSourceForm() {
<div class="relative">
<input id="use_builtin_auth_source" name="use_builtin_auth_source" type="checkbox"
class="sr-only"
x-model="useBuiltinAuth"
x-model="useBuiltinAuthSource"
:value="useBuiltinAuthSource ? 'true' : 'false'"
/>
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
:class="useBuiltinAuth ? 'transform translate-x-6 bg-primary-500' : ''"></div>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="useBuiltinAuthSource ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Use rclone's built-in Google authentication (recommended)
</div>
</label>
@@ -27,11 +28,11 @@ templ GoogleDriveSourceForm() {
</p>
</div>
<div x-bind:class="{ 'opacity-50': useBuiltinAuth }">
<div x-bind:class="{ 'opacity-50': useBuiltinAuthSource }">
<div>
<label for="source_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client ID
<span x-show="useBuiltinAuth" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@@ -39,7 +40,7 @@ templ GoogleDriveSourceForm() {
</div>
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuth"
x-bind:disabled="useBuiltinAuthSource"
placeholder="Google Drive OAuth Client ID" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
@@ -50,7 +51,7 @@ templ GoogleDriveSourceForm() {
<div class="mt-4">
<label for="source_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client Secret
<span x-show="useBuiltinAuth" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@@ -58,7 +59,7 @@ templ GoogleDriveSourceForm() {
</div>
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuth"
x-bind:disabled="useBuiltinAuthSource"
placeholder="Google Drive OAuth Client Secret" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
@@ -112,15 +113,60 @@ templ GoogleDriveSourceForm() {
</p>
</div>
<div class="p-4 bg-amber-50 rounded-lg border border-amber-100">
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-amber-500"></i>
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800">Authentication Required</h3>
<div class="mt-2 text-sm text-amber-700">
<p>After saving this configuration, you'll need to authenticate with Google Drive on the configurations page.</p>
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>After saving this configuration, you'll need to authenticate with Google Drive:</p>
<ol class="list-decimal list-inside mt-2 space-y-1">
<li>Go to the configurations page</li>
<li>Click the "Authenticate" button for this configuration</li>
<li>You'll be redirected to Google to grant access permissions</li>
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
</ol>
</div>
</div>
</div>
</div>
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
<template x-if="useBuiltinAuthSource">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>No need to create your own Google API credentials</li>
<li>Authentication happens through Google's secure OAuth flow</li>
<li>Automatic redirection back to GoMFT after authentication</li>
</ul>
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
<i class="fas fa-exclamation-triangle mr-1"></i>
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
</p>
</div>
</template>
<template x-if="!useBuiltinAuthSource">
<div>
<p>To use Google Drive with your own credentials:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
<li>Create a project and enable the Google Drive API</li>
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
</ol>
</div>
</template>
</div>
</div>
</div>
+46 -52
View File
@@ -1,25 +1,25 @@
package source
templ GooglePhotosSourceForm() {
<div class="space-y-6" x-init="$watch('useBuiltinAuth', value => {
<div class="space-y-6" x-init="$watch('useBuiltinAuthSource', value => {
if(value) {
sourceClientId = '';
sourceClientSecret = '';
}
})">
<div class="mb-6">
<label for="use_builtin_auth" class="flex items-center cursor-pointer">
<label for="use_builtin_auth_source" class="flex items-center cursor-pointer">
<div class="relative">
<input id="use_builtin_auth" name="use_builtin_auth" type="checkbox"
<input id="use_builtin_auth_source" name="use_builtin_auth_source" type="checkbox"
class="sr-only"
x-model="useBuiltinAuth"
value="true"
x-model="useBuiltinAuthSource"
:value="useBuiltinAuthSource ? 'true' : 'false'"
/>
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
:class="useBuiltinAuth ? 'transform translate-x-6 bg-primary-500' : ''"></div>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="useBuiltinAuthSource ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Use rclone's built-in Google authentication (recommended)
</div>
</label>
@@ -28,11 +28,11 @@ templ GooglePhotosSourceForm() {
</p>
</div>
<div x-bind:class="{ 'opacity-50': useBuiltinAuth }">
<div x-bind:class="{ 'opacity-50': useBuiltinAuthSource }">
<div>
<label for="source_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client ID
<span x-show="useBuiltinAuth" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@@ -40,7 +40,7 @@ templ GooglePhotosSourceForm() {
</div>
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuth"
x-bind:disabled="useBuiltinAuthSource"
placeholder="Google Photos OAuth Client ID" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
@@ -51,7 +51,7 @@ templ GooglePhotosSourceForm() {
<div class="mt-4">
<label for="source_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client Secret
<span x-show="useBuiltinAuth" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@@ -59,7 +59,7 @@ templ GooglePhotosSourceForm() {
</div>
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuth"
x-bind:disabled="useBuiltinAuthSource"
placeholder="Google Photos OAuth Client Secret" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
@@ -74,13 +74,12 @@ templ GooglePhotosSourceForm() {
<input id="source_read_only" name="source_read_only" type="checkbox"
class="sr-only"
x-model="sourceReadOnly"
value="true"
/>
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="sourceReadOnly ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Read-only mode
</div>
</label>
@@ -110,13 +109,12 @@ templ GooglePhotosSourceForm() {
<input id="source_include_archived" name="source_include_archived" type="checkbox"
class="sr-only"
x-model="sourceIncludeArchived"
value="true"
/>
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="sourceIncludeArchived ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Include archived media
</div>
</label>
@@ -133,68 +131,64 @@ templ GooglePhotosSourceForm() {
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Album path (e.g., /album/my-photos)" />
placeholder="Album path (e.g., album/my-photos)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Path within Google Photos to download files from
</p>
</div>
<div class="p-4 bg-amber-50 rounded-lg border border-amber-100">
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-amber-500"></i>
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800">Important: Authentication Required</h3>
<div class="mt-2 text-sm text-amber-700">
<p><strong>After saving this configuration</strong>, you will need to authenticate with Google Photos.</p>
<p class="mt-1">The authentication process will require you to:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
<li>Visit a Google authorization URL</li>
<li>Sign in to your Google account</li>
<li>Grant permission to access your Google Photos</li>
<li>Copy the authorization code back to this application</li>
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>After saving this configuration, you'll need to authenticate with Google Photos:</p>
<ol class="list-decimal list-inside mt-2 space-y-1">
<li>Go to the configurations page</li>
<li>Click the "Authenticate" button for this configuration</li>
<li>You'll be redirected to Google to grant access permissions</li>
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
</ol>
<p class="mt-2 text-xs">
This is a one-time process for each configuration. The application will store your authorization token securely.
</p>
</div>
</div>
</div>
</div>
<div class="p-4 bg-blue-50 rounded-lg border border-blue-100">
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400"></i>
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700">
<template x-if="useBuiltinAuth">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
<template x-if="useBuiltinAuthSource">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>No need to create your own Google API credentials</li>
<li>Authentication happens through a browser window</li>
<li>You will need to manually copy the authorization code back</li>
<li>Authentication happens through Google's secure OAuth flow</li>
<li>Automatic redirection back to GoMFT after authentication</li>
</ul>
<p class="mt-2 text-xs text-amber-600">
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
<i class="fas fa-exclamation-triangle mr-1"></i>
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
</p>
</div>
</template>
<template x-if="!useBuiltinAuth">
<template x-if="!useBuiltinAuthSource">
<div>
<p>To use Google Photos with your own credentials:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 underline">Google Cloud Console</a></li>
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
<li>Create a project and enable the Google Photos API</li>
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
<li>Set authorized redirect URI to <code class="bg-blue-100 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
</ol>
</div>
</template>
@@ -203,14 +197,14 @@ templ GooglePhotosSourceForm() {
</div>
</div>
<div class="p-4 bg-yellow-50 rounded-lg border border-yellow-100">
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/50 rounded-lg border border-yellow-100 dark:border-yellow-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-circle text-yellow-500"></i>
<i class="fas fa-exclamation-circle text-yellow-500 dark:text-yellow-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">Important Note About Google Photos</h3>
<div class="mt-2 text-sm text-yellow-700">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">Important Note About Google Photos</h3>
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<p>When downloading from Google Photos, be aware that some original metadata may not be preserved. Google Photos processes and may compress some images upon upload.</p>
</div>
</div>
+19 -6
View File
@@ -6,7 +6,7 @@ templ LocalSourceForm() {
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Local Path</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
@@ -14,13 +14,26 @@ templ LocalSourceForm() {
id="source_path"
x-model="sourcePath"
required
aria-describedby="source_path_help"
:class="{ 'border-red-300 dark:border-red-700': sourcePathValid === false, 'border-green-300 dark:border-green-700': sourcePathValid === true }"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="/path/to/source"/>
placeholder="Local directory path (e.g., /path/to/files)"
/>
</div>
<p id="source_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Absolute path to the local directory containing the files to transfer.
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Full path to the local directory containing your files
</p>
<!-- Path validation message -->
<template x-if="sourcePathValid !== null">
<p x-show="sourcePathError" class="mt-1 text-sm" :class="sourcePathValid ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'" x-text="sourcePathError"></p>
</template>
</div>
<button
type="button"
class="btn-secondary h-10 whitespace-nowrap"
@click="checkPath(sourcePath, 'source')"
>
<i class="fas fa-check-circle mr-2"></i>
Check Location
</button>
</div>
}
}
+1 -1
View File
@@ -711,7 +711,7 @@ func handleListHistory(database *db.DB) gin.HandlerFunc {
// For now, just return the most recent 100 history entries for the user's jobs
var history []db.JobHistory
err := database.DB.
Joins("JOIN jobs ON job_histories.job_id = jobs.id").
Joins("JOIN jobs ON job_history.job_id = jobs.id").
Where("jobs.created_by = ?", userID).
Order("start_time DESC").
Limit(100).
+34 -25
View File
@@ -11,7 +11,7 @@ import (
"time"
"github.com/glebarez/sqlite"
"github.com/starfleetcptn/gomft/internal/auth"
"github.com/starfleetcptn/gomft/internal/db/migrations"
"gorm.io/gorm"
)
@@ -109,7 +109,8 @@ type TransferConfig struct {
DestStartYear int `form:"dest_start_year"` // For Google Photos
DestIncludeArchived *bool `form:"dest_include_archived"` // For Google Photos
// Security fields
UseBuiltinAuth *bool `form:"use_builtin_auth"` // For Google and other OAuth services
UseBuiltinAuthSource *bool `form:"use_builtin_auth_source"` // For Google and other OAuth services
UseBuiltinAuthDest *bool `form:"use_builtin_auth_dest"` // For Google and other OAuth services
GoogleDriveAuthenticated *bool // Whether Google Drive auth is completed
// General fields
ArchivePath string `form:"archive_path"`
@@ -250,10 +251,10 @@ func Initialize(dbPath string) (*DB, error) {
return nil, fmt.Errorf("failed to connect to database: %v", err)
}
// Auto migrate the schema
err = db.AutoMigrate(&User{}, &auth.PasswordHistory{}, &PasswordResetToken{}, &TransferConfig{}, &Job{}, &JobHistory{}, &FileMetadata{})
if err != nil {
return nil, fmt.Errorf("failed to migrate database: %v", err)
// Initialize and run migrations
m := migrations.InitMigrations(db)
if err := m.Migrate(); err != nil {
return nil, fmt.Errorf("failed to run migrations: %v", err)
}
return &DB{DB: db}, nil
@@ -627,7 +628,7 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
}
case "google_drive":
case "gdrive":
args := []string{
"config", "create", sourceName, "drive",
"client_id", config.SourceClientID,
@@ -906,24 +907,6 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
args = append(args, "include_archived", "true")
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
}
case "google_drive":
args := []string{
"config", "create", destName, "drive",
"client_id", config.DestClientID,
"client_secret", config.DestClientSecret,
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
if config.DestTeamDrive != "" {
args = append(args, "team_drive", config.DestTeamDrive)
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
@@ -1461,3 +1444,29 @@ func (db *DB) GetGDriveCredentialsFromConfig(config *TransferConfig) (string, st
return "", ""
}
// GetUseBuiltinAuthSource returns the value of UseBuiltinAuthSource with a default if nil
func (tc *TransferConfig) GetUseBuiltinAuthSource() bool {
if tc.UseBuiltinAuthSource == nil {
return true // Default to true if not set
}
return *tc.UseBuiltinAuthSource
}
// SetUseBuiltinAuthSource sets the UseBuiltinAuthSource field
func (tc *TransferConfig) SetUseBuiltinAuthSource(value bool) {
tc.UseBuiltinAuthSource = &value
}
// GetUseBuiltinAuthDest returns the value of UseBuiltinAuthDest with a default if nil
func (tc *TransferConfig) GetUseBuiltinAuthDest() bool {
if tc.UseBuiltinAuthDest == nil {
return true // Default to true if not set
}
return *tc.UseBuiltinAuthDest
}
// SetUseBuiltinAuthDest sets the UseBuiltinAuthDest field
func (tc *TransferConfig) SetUseBuiltinAuthDest(value bool) {
tc.UseBuiltinAuthDest = &value
}
+29 -26
View File
@@ -853,7 +853,7 @@ func TestGoogleDriveTransferConfig(t *testing.T) {
// Test 1: Create config with Google Drive as source
googleSourceConfig := &TransferConfig{
Name: "Google Drive Source Test",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "/path/in/google/drive",
SourceClientID: "google_client_id",
SourceClientSecret: "google_client_secret",
@@ -878,7 +878,7 @@ func TestGoogleDriveTransferConfig(t *testing.T) {
Name: "Google Drive Destination Test",
SourceType: "local",
SourcePath: "/local/source/path",
DestinationType: "google_drive",
DestinationType: "gdrive",
DestinationPath: "/path/in/google/drive",
DestClientID: "google_client_id",
DestClientSecret: "google_client_secret",
@@ -898,12 +898,12 @@ func TestGoogleDriveTransferConfig(t *testing.T) {
// Test 3: Create config with Google Drive as both source and destination
googleBothConfig := &TransferConfig{
Name: "Google Drive Both Test",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "/source/path/in/google/drive",
SourceClientID: "source_google_client_id",
SourceClientSecret: "source_google_client_secret",
SourceTeamDrive: "source_team_drive_id",
DestinationType: "google_drive",
DestinationType: "gdrive",
DestinationPath: "/dest/path/in/google/drive",
DestClientID: "dest_google_client_id",
DestClientSecret: "dest_google_client_secret",
@@ -923,7 +923,7 @@ func TestGoogleDriveTransferConfig(t *testing.T) {
// Test retrieving and verifying Google Drive configs
retrievedSourceConfig, err := db.GetTransferConfig(googleSourceConfig.ID)
assert.NoError(t, err)
assert.Equal(t, "google_drive", retrievedSourceConfig.SourceType)
assert.Equal(t, "gdrive", retrievedSourceConfig.SourceType)
assert.Equal(t, "/path/in/google/drive", retrievedSourceConfig.SourcePath)
assert.Equal(t, "google_client_id", retrievedSourceConfig.SourceClientID)
assert.Equal(t, "team_drive_id", retrievedSourceConfig.SourceTeamDrive)
@@ -931,7 +931,7 @@ func TestGoogleDriveTransferConfig(t *testing.T) {
retrievedDestConfig, err := db.GetTransferConfig(googleDestConfig.ID)
assert.NoError(t, err)
assert.Equal(t, "google_drive", retrievedDestConfig.DestinationType)
assert.Equal(t, "gdrive", retrievedDestConfig.DestinationType)
assert.Equal(t, "/path/in/google/drive", retrievedDestConfig.DestinationPath)
assert.Equal(t, "google_client_id", retrievedDestConfig.DestClientID)
assert.Equal(t, "team_drive_id", retrievedDestConfig.DestTeamDrive)
@@ -986,7 +986,7 @@ func TestGoogleDriveJobExecution(t *testing.T) {
// Create a test transfer config with Google Drive as source
googleConfig := &TransferConfig{
Name: "Google Drive Job Test",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "/source/path/in/google/drive",
SourceClientID: "google_client_id",
SourceClientSecret: "google_client_secret",
@@ -1149,7 +1149,7 @@ func TestGoogleDriveAuthentication(t *testing.T) {
// Create a Google Drive config that requires authentication
googleConfig := &TransferConfig{
Name: "Google Drive Auth Test",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "/source/path",
SourceClientID: "test_client_id",
SourceClientSecret: "test_client_secret",
@@ -1234,7 +1234,7 @@ func TestGoogleDriveErrorHandling(t *testing.T) {
// Test 1: Create a config with missing required fields
incompleteConfig := &TransferConfig{
Name: "Incomplete Google Drive Config",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "", // Missing path
DestinationType: "local",
DestinationPath: "/local/path",
@@ -1249,7 +1249,7 @@ func TestGoogleDriveErrorHandling(t *testing.T) {
// Test 2: Config with invalid Team Drive ID
invalidTeamDriveConfig := &TransferConfig{
Name: "Invalid Team Drive Config",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "/test/path",
SourceClientID: "test_client_id",
SourceClientSecret: "test_client_secret",
@@ -1279,7 +1279,7 @@ func TestGoogleDriveErrorHandling(t *testing.T) {
// Test 3: Test authentication error scenario - using malformed token
badTokenConfig := &TransferConfig{
Name: "Bad Token Config",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "/test/path",
SourceClientID: "test_client_id",
SourceClientSecret: "test_client_secret",
@@ -1325,7 +1325,7 @@ func TestGoogleDriveTeamDrive(t *testing.T) {
// Test 1: Configure source with Team Drive
teamDriveSourceConfig := &TransferConfig{
Name: "Team Drive Source Test",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "/shared/documents",
SourceClientID: "test_client_id",
SourceClientSecret: "test_client_secret",
@@ -1350,7 +1350,7 @@ func TestGoogleDriveTeamDrive(t *testing.T) {
Name: "Team Drive Destination Test",
SourceType: "local",
SourcePath: "/local/source",
DestinationType: "google_drive",
DestinationType: "gdrive",
DestinationPath: "/team/drive/path",
DestClientID: "test_client_id",
DestClientSecret: "test_client_secret",
@@ -1370,12 +1370,12 @@ func TestGoogleDriveTeamDrive(t *testing.T) {
// Test 3: Configure both source and destination with Team Drive
teamDriveBothConfig := &TransferConfig{
Name: "Team Drive Both Test",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "/source/team/drive/path",
SourceClientID: "source_client_id",
SourceClientSecret: "source_client_secret",
SourceTeamDrive: "source_team_drive_id",
DestinationType: "google_drive",
DestinationType: "gdrive",
DestinationPath: "/dest/team/drive/path",
DestClientID: "dest_client_id",
DestClientSecret: "dest_client_secret",
@@ -1434,7 +1434,7 @@ func TestGooglePhotosTransferConfig(t *testing.T) {
// Test 1: Create config with Google Photos as source
sourceReadOnly := true
sourceIncludeArchived := false
useBuiltinAuth := true
useBuiltinAuthSource := true
googleSourceConfig := &TransferConfig{
Name: "Google Photos Source Test",
SourceType: "gphotos",
@@ -1444,7 +1444,7 @@ func TestGooglePhotosTransferConfig(t *testing.T) {
SourceReadOnly: &sourceReadOnly,
SourceStartYear: 2015,
SourceIncludeArchived: &sourceIncludeArchived,
UseBuiltinAuth: &useBuiltinAuth,
UseBuiltinAuthSource: &useBuiltinAuthSource,
DestinationType: "local",
DestinationPath: "/local/destination/path",
FilePattern: "*.jpg",
@@ -1463,6 +1463,7 @@ func TestGooglePhotosTransferConfig(t *testing.T) {
// Test 2: Create config with Google Photos as destination
destReadOnly := false
destIncludeArchived := true
useBuiltinAuthDest := true
googleDestConfig := &TransferConfig{
Name: "Google Photos Destination Test",
SourceType: "local",
@@ -1474,7 +1475,7 @@ func TestGooglePhotosTransferConfig(t *testing.T) {
DestReadOnly: &destReadOnly,
DestStartYear: 2018,
DestIncludeArchived: &destIncludeArchived,
UseBuiltinAuth: &useBuiltinAuth,
UseBuiltinAuthDest: &useBuiltinAuthDest,
FilePattern: "*.png",
CreatedBy: testUser.ID,
}
@@ -1504,7 +1505,8 @@ func TestGooglePhotosTransferConfig(t *testing.T) {
DestReadOnly: &destReadOnly,
DestStartYear: 2020,
DestIncludeArchived: &destIncludeArchived,
UseBuiltinAuth: &useBuiltinAuth,
UseBuiltinAuthSource: &useBuiltinAuthSource,
UseBuiltinAuthDest: &useBuiltinAuthDest,
FilePattern: "*.jpeg",
CreatedBy: testUser.ID,
}
@@ -1524,7 +1526,7 @@ func TestGooglePhotosTransferConfig(t *testing.T) {
assert.Equal(t, sourceReadOnly, *retrievedConfig.SourceReadOnly)
assert.Equal(t, 2015, retrievedConfig.SourceStartYear)
assert.Equal(t, sourceIncludeArchived, *retrievedConfig.SourceIncludeArchived)
assert.Equal(t, useBuiltinAuth, *retrievedConfig.UseBuiltinAuth)
assert.Equal(t, useBuiltinAuthSource, *retrievedConfig.UseBuiltinAuthSource)
assert.Equal(t, true, retrievedConfig.GetGoogleAuthenticated())
retrievedConfig, err = db.GetTransferConfig(googleDestConfig.ID)
@@ -1533,7 +1535,7 @@ func TestGooglePhotosTransferConfig(t *testing.T) {
assert.Equal(t, destReadOnly, *retrievedConfig.DestReadOnly)
assert.Equal(t, 2018, retrievedConfig.DestStartYear)
assert.Equal(t, destIncludeArchived, *retrievedConfig.DestIncludeArchived)
assert.Equal(t, useBuiltinAuth, *retrievedConfig.UseBuiltinAuth)
assert.Equal(t, useBuiltinAuthDest, *retrievedConfig.UseBuiltinAuthDest)
assert.Equal(t, true, retrievedConfig.GetGoogleAuthenticated())
}
@@ -1573,7 +1575,7 @@ func TestGooglePhotosRcloneConfig(t *testing.T) {
// TEST 1: Google Photos as source with standard options
sourceReadOnly := true
sourceIncludeArchived := false
useBuiltinAuth := true
useBuiltinAuthSource := true
gphotosSourceConfig := &TransferConfig{
ID: 1, // Force ID for predictable config path
Name: "Google Photos Source Config",
@@ -1584,7 +1586,7 @@ func TestGooglePhotosRcloneConfig(t *testing.T) {
SourceReadOnly: &sourceReadOnly,
SourceStartYear: 2015,
SourceIncludeArchived: &sourceIncludeArchived,
UseBuiltinAuth: &useBuiltinAuth,
UseBuiltinAuthSource: &useBuiltinAuthSource,
DestinationType: "local",
DestinationPath: "/tmp/destination",
FilePattern: "*.jpg",
@@ -1617,6 +1619,7 @@ func TestGooglePhotosRcloneConfig(t *testing.T) {
// TEST 2: Google Photos as destination with authenticated token
destReadOnly := false
destIncludeArchived := true
useBuiltinAuthDest := true
gphotosDestConfig := &TransferConfig{
ID: 2, // Force ID for predictable config path
Name: "Google Photos Destination Config",
@@ -1629,7 +1632,7 @@ func TestGooglePhotosRcloneConfig(t *testing.T) {
DestReadOnly: &destReadOnly,
DestStartYear: 2018,
DestIncludeArchived: &destIncludeArchived,
UseBuiltinAuth: &useBuiltinAuth,
UseBuiltinAuthDest: &useBuiltinAuthDest,
FilePattern: "*.png",
CreatedBy: testUser.ID,
}
@@ -1686,7 +1689,7 @@ func TestGooglePhotosAuthentication(t *testing.T) {
// Create a transfer config with Google Photos
readOnly := true
includeArchived := false
useBuiltinAuth := true
useBuiltinAuthSource := true
gPhotosConfig := &TransferConfig{
Name: "Test Google Photos Auth",
SourceType: "gphotos",
@@ -1696,7 +1699,7 @@ func TestGooglePhotosAuthentication(t *testing.T) {
SourceReadOnly: &readOnly,
SourceStartYear: 2015,
SourceIncludeArchived: &includeArchived,
UseBuiltinAuth: &useBuiltinAuth,
UseBuiltinAuthSource: &useBuiltinAuthSource,
DestinationType: "local",
DestinationPath: "/tmp/destination",
FilePattern: "*.jpg",
@@ -0,0 +1,278 @@
package migrations
import (
"fmt"
"os"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
func InitialSchema() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "001_initial_schema",
Migrate: func(tx *gorm.DB) error {
// Check if any tables exist (indicating an existing database)
var count int64
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
return fmt.Errorf("failed to check for existing tables: %v", err)
}
// If tables exist, create a backup
if count > 0 {
// Get the database path
sqlDB, err := tx.DB()
if err != nil {
return fmt.Errorf("failed to get underlying database: %v", err)
}
var seq int
var name, dbPath string
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
return fmt.Errorf("failed to get database path: %v", err)
}
// Create backup file with timestamp
backupFile := fmt.Sprintf("%s.backup.%s", dbPath, time.Now().Format("20060102_150405"))
// Read original database
data, err := os.ReadFile(dbPath)
if err != nil {
return fmt.Errorf("failed to read database for backup: %v", err)
}
// Write backup
if err := os.WriteFile(backupFile, data, 0600); err != nil {
return fmt.Errorf("failed to create database backup: %v", err)
}
fmt.Printf("Created database backup at: %s\n", backupFile)
}
// Disable foreign key constraints while creating tables
if err := tx.Exec("PRAGMA foreign_keys = OFF").Error; err != nil {
return fmt.Errorf("failed to disable foreign key constraints: %v", err)
}
defer func() {
if err := tx.Exec("PRAGMA foreign_keys = ON").Error; err != nil {
fmt.Printf("Warning: failed to re-enable foreign key constraints: %v\n", err)
}
}()
// Change the table name from job_histories to job_history
if err := tx.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='job_histories'").Scan(&count).Error; err != nil {
return err
}
if count > 0 {
if err := tx.Exec("ALTER TABLE job_histories RENAME TO job_history").Error; err != nil {
return err
}
}
// Change the table name from password_histories to password_history
count = 0
if err := tx.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='password_histories'").Scan(&count).Error; err != nil {
return err
}
if count > 0 {
if err := tx.Exec("ALTER TABLE password_histories RENAME TO password_history").Error; err != nil {
return err
}
}
// Create Users table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
is_admin BOOLEAN DEFAULT FALSE,
last_password_change DATETIME,
failed_login_attempts INTEGER DEFAULT 0,
account_locked BOOLEAN DEFAULT FALSE,
lockout_until DATETIME,
theme VARCHAR(255) DEFAULT 'light',
created_at DATETIME,
updated_at DATETIME
)`).Error; err != nil {
return err
}
// Create PasswordHistory table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS password_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id)
)`).Error; err != nil {
return err
}
// Create PasswordResetToken table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token VARCHAR(255) NOT NULL,
expires_at DATETIME NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id)
)`).Error; err != nil {
return err
}
// Create TransferConfigs table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS transfer_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
source_type VARCHAR(255) NOT NULL,
source_path TEXT NOT NULL,
source_host VARCHAR(255),
source_port INTEGER DEFAULT 22,
source_user VARCHAR(255),
source_key_file TEXT,
source_bucket VARCHAR(255),
source_region VARCHAR(255),
source_access_key VARCHAR(255),
source_endpoint VARCHAR(255),
source_share VARCHAR(255),
source_domain VARCHAR(255),
source_passive_mode BOOLEAN DEFAULT TRUE,
source_client_id VARCHAR(255),
source_drive_id VARCHAR(255),
source_team_drive VARCHAR(255),
source_read_only BOOLEAN,
source_start_year INTEGER,
source_include_archived BOOLEAN,
file_pattern VARCHAR(255) DEFAULT '*',
output_pattern TEXT,
destination_type VARCHAR(255) NOT NULL,
destination_path TEXT NOT NULL,
dest_host VARCHAR(255),
dest_port INTEGER DEFAULT 22,
dest_user VARCHAR(255),
dest_key_file TEXT,
dest_bucket VARCHAR(255),
dest_region VARCHAR(255),
dest_access_key VARCHAR(255),
dest_endpoint VARCHAR(255),
dest_share VARCHAR(255),
dest_domain VARCHAR(255),
dest_passive_mode BOOLEAN DEFAULT TRUE,
dest_client_id VARCHAR(255),
dest_drive_id VARCHAR(255),
dest_team_drive VARCHAR(255),
dest_read_only BOOLEAN,
dest_start_year INTEGER,
dest_include_archived BOOLEAN,
use_builtin_auth_source BOOLEAN DEFAULT TRUE,
use_builtin_auth_dest BOOLEAN DEFAULT TRUE,
google_drive_authenticated BOOLEAN,
archive_path TEXT,
archive_enabled BOOLEAN DEFAULT FALSE,
rclone_flags TEXT,
delete_after_transfer BOOLEAN DEFAULT FALSE,
skip_processed_files BOOLEAN DEFAULT TRUE,
max_concurrent_transfers INTEGER DEFAULT 4,
created_by INTEGER,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (created_by) REFERENCES users(id)
)`).Error; err != nil {
return err
}
// Create Jobs table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255),
config_id INTEGER NOT NULL,
config_ids TEXT,
schedule VARCHAR(255) NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
last_run DATETIME,
next_run DATETIME,
webhook_enabled BOOLEAN DEFAULT FALSE,
webhook_url TEXT,
webhook_secret TEXT,
webhook_headers TEXT,
notify_on_success BOOLEAN DEFAULT TRUE,
notify_on_failure BOOLEAN DEFAULT TRUE,
created_by INTEGER,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (config_id) REFERENCES transfer_configs(id),
FOREIGN KEY (created_by) REFERENCES users(id)
)`).Error; err != nil {
return err
}
// Create JobHistory table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS job_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
config_id INTEGER DEFAULT 0,
start_time DATETIME NOT NULL,
end_time DATETIME,
status VARCHAR(255) NOT NULL,
bytes_transferred INTEGER,
files_transferred INTEGER,
error_message TEXT,
FOREIGN KEY (job_id) REFERENCES jobs(id)
)`).Error; err != nil {
return err
}
// Create FileMetadata table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS file_metadata (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
config_id INTEGER DEFAULT 0,
file_name VARCHAR(255) NOT NULL,
original_path TEXT NOT NULL,
file_size INTEGER NOT NULL,
file_hash VARCHAR(255),
creation_time DATETIME,
mod_time DATETIME,
processed_time DATETIME NOT NULL,
destination_path TEXT NOT NULL,
status VARCHAR(255) NOT NULL,
error_message TEXT,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (job_id) REFERENCES jobs(id)
)`).Error; err != nil {
return err
}
// Re-enable foreign key constraints and verify integrity
if err := tx.Exec("PRAGMA foreign_key_check").Error; err != nil {
return fmt.Errorf("foreign key integrity check failed: %v", err)
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Drop tables in reverse order to handle foreign key constraints
tables := []string{
"file_metadata",
"job_history",
"jobs",
"transfer_configs",
"password_reset_tokens",
"password_history",
"users",
}
for _, table := range tables {
if err := tx.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", table)).Error; err != nil {
return err
}
}
return nil
},
}
}
@@ -0,0 +1,49 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// UpdateBuiltinAuthFields updates the use_builtin_auth field to separate source and destination fields
func UpdateBuiltinAuthFields() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "002_update_builtin_auth_fields",
Migrate: func(tx *gorm.DB) error {
// First, add the new columns
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN use_builtin_auth_source BOOLEAN`).Error; err != nil {
return err
}
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN use_builtin_auth_dest BOOLEAN`).Error; err != nil {
return err
}
// Copy the old value to both new columns
if err := tx.Exec(`UPDATE transfer_configs SET
use_builtin_auth_source = use_builtin_auth,
use_builtin_auth_dest = use_builtin_auth`).Error; err != nil {
return err
}
// Drop the old column
return tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN use_builtin_auth`).Error
},
Rollback: func(tx *gorm.DB) error {
// Add back the original column
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN use_builtin_auth BOOLEAN`).Error; err != nil {
return err
}
// Copy the source value back (could also use dest, they should be the same)
if err := tx.Exec(`UPDATE transfer_configs SET use_builtin_auth = use_builtin_auth_source`).Error; err != nil {
return err
}
// Drop the new columns
if err := tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN use_builtin_auth_source`).Error; err != nil {
return err
}
return tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN use_builtin_auth_dest`).Error
},
}
}
@@ -0,0 +1,31 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// UpdateGDriveType updates the source_type and destination_type from 'google_drive' to 'gdrive'
func UpdateGDriveType() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "003_update_gdrive_type",
Migrate: func(tx *gorm.DB) error {
// Update source_type
if err := tx.Exec(`UPDATE transfer_configs SET source_type = 'gdrive' WHERE source_type = 'google_drive'`).Error; err != nil {
return err
}
// Update destination_type
return tx.Exec(`UPDATE transfer_configs SET destination_type = 'gdrive' WHERE destination_type = 'google_drive'`).Error
},
Rollback: func(tx *gorm.DB) error {
// Revert source_type
if err := tx.Exec(`UPDATE transfer_configs SET source_type = 'google_drive' WHERE source_type = 'gdrive'`).Error; err != nil {
return err
}
// Revert destination_type
return tx.Exec(`UPDATE transfer_configs SET destination_type = 'google_drive' WHERE destination_type = 'gdrive'`).Error
},
}
}
@@ -1,63 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddCloudStorageFields adds fields for WebDAV, NextCloud, OneDrive, and Google Drive
func AddCloudStorageFields() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "add_cloud_storage_fields",
Migrate: func(tx *gorm.DB) error {
// Add source fields
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_client_id VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_drive_id VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_team_drive VARCHAR(255)").Error; err != nil {
return err
}
// Add destination fields
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_client_id VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_drive_id VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_team_drive VARCHAR(255)").Error; err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Drop source fields
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_client_id").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_drive_id").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_team_drive").Error; err != nil {
return err
}
// Drop destination fields
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_client_id").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_drive_id").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_team_drive").Error; err != nil {
return err
}
return nil
},
}
}
@@ -1,19 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddDeleteAfterTransferColumn adds the delete_after_transfer column to transfer_configs table
func AddDeleteAfterTransferColumn() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "add_delete_after_transfer_column",
Migrate: func(tx *gorm.DB) error {
return tx.Exec("ALTER TABLE transfer_configs ADD COLUMN delete_after_transfer BOOLEAN NOT NULL DEFAULT false").Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec("ALTER TABLE transfer_configs DROP COLUMN delete_after_transfer").Error
},
}
}
@@ -1,21 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddGoogleDriveAuthenticated adds the GoogleDriveAuthenticated field to the transfer_configs table
func AddGoogleDriveAuthenticated() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20240315_add_google_drive_authenticated",
Migrate: func(tx *gorm.DB) error {
// Add the GoogleDriveAuthenticated column with a default value of false
return tx.Exec("ALTER TABLE transfer_configs ADD COLUMN google_drive_authenticated BOOLEAN DEFAULT false").Error
},
Rollback: func(tx *gorm.DB) error {
// Remove the GoogleDriveAuthenticated column
return tx.Exec("ALTER TABLE transfer_configs DROP COLUMN google_drive_authenticated").Error
},
}
}
@@ -1,61 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddGooglePhotosSupport adds Google Photos related fields to the transfer_configs table
func AddGooglePhotosSupport() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20240518_add_google_photos_support",
Migrate: func(tx *gorm.DB) error {
// Add Google Photos source fields
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_read_only BOOLEAN DEFAULT false").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_start_year INTEGER DEFAULT 0").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_include_archived BOOLEAN DEFAULT false").Error; err != nil {
return err
}
// Add Google Photos destination fields
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_read_only BOOLEAN DEFAULT false").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_start_year INTEGER DEFAULT 0").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_include_archived BOOLEAN DEFAULT false").Error; err != nil {
return err
}
// Add OAuth field
return tx.Exec("ALTER TABLE transfer_configs ADD COLUMN use_builtin_auth BOOLEAN DEFAULT true").Error
},
Rollback: func(tx *gorm.DB) error {
// Remove all added columns in reverse order
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN use_builtin_auth").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_include_archived").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_start_year").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_read_only").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_include_archived").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_start_year").Error; err != nil {
return err
}
return tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_read_only").Error
},
}
}
@@ -1,21 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddMaxConcurrentTransfersColumn adds the max_concurrent_transfers column to transfer_configs table
func AddMaxConcurrentTransfersColumn() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20250311_add_max_concurrent_transfers",
Migrate: func(tx *gorm.DB) error {
// Add max_concurrent_transfers column with default value of 4
return tx.Exec("ALTER TABLE transfer_configs ADD COLUMN max_concurrent_transfers INTEGER DEFAULT 4").Error
},
Rollback: func(tx *gorm.DB) error {
// Drop the column if needed
return tx.Exec("ALTER TABLE transfer_configs DROP COLUMN max_concurrent_transfers").Error
},
}
}
@@ -1,49 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddMultiConfigSupport adds support for multiple configurations per job
func AddMultiConfigSupport() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20250315_add_multi_config_support",
Migrate: func(tx *gorm.DB) error {
// Add config_ids column to jobs table
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN config_ids TEXT").Error; err != nil {
return err
}
// Add config_id column to job_histories table
if err := tx.Exec("ALTER TABLE job_histories ADD COLUMN config_id INTEGER").Error; err != nil {
return err
}
// Add config_id column to file_metadata table
if err := tx.Exec("ALTER TABLE file_metadata ADD COLUMN config_id INTEGER").Error; err != nil {
return err
}
// Update existing jobs to set the config_ids field to match the current config_id
if err := tx.Exec("UPDATE jobs SET config_ids = config_id WHERE config_id > 0").Error; err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Drop the config_id columns from job_histories and file_metadata
if err := tx.Exec("ALTER TABLE job_histories DROP COLUMN config_id").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE file_metadata DROP COLUMN config_id").Error; err != nil {
return err
}
// Drop the config_ids column from jobs
return tx.Exec("ALTER TABLE jobs DROP COLUMN config_ids").Error
},
}
}
@@ -1,21 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddSkipProcessedFilesColumn adds the skip_processed_files column to transfer_configs table
func AddSkipProcessedFilesColumn() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20250310_add_skip_processed_files",
Migrate: func(tx *gorm.DB) error {
// Add skip_processed_files column with default value of true
return tx.Exec("ALTER TABLE transfer_configs ADD COLUMN skip_processed_files BOOLEAN DEFAULT true").Error
},
Rollback: func(tx *gorm.DB) error {
// Drop the column if needed
return tx.Exec("ALTER TABLE transfer_configs DROP COLUMN skip_processed_files").Error
},
}
}
@@ -1,61 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddWebhookSupport adds webhook notification fields to the jobs table
func AddWebhookSupport() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20240618_add_webhook_support",
Migrate: func(tx *gorm.DB) error {
// Add webhook URL field
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN webhook_enabled BOOLEAN DEFAULT false").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN webhook_url VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN webhook_secret VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN webhook_headers TEXT").Error; err != nil {
return err
}
// Add notification settings
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN notify_on_success BOOLEAN DEFAULT true").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN notify_on_failure BOOLEAN DEFAULT true").Error; err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Drop the webhook fields from jobs
if err := tx.Exec("ALTER TABLE jobs DROP COLUMN webhook_enabled").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs DROP COLUMN webhook_url").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs DROP COLUMN webhook_secret").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs DROP COLUMN webhook_headers").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs DROP COLUMN notify_on_success").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs DROP COLUMN notify_on_failure").Error; err != nil {
return err
}
return nil
},
}
}
+3 -10
View File
@@ -8,16 +8,9 @@ import (
// InitMigrations initializes the migrations
func InitMigrations(db *gorm.DB) *gormigrate.Gormigrate {
migrations := []*gormigrate.Migration{
// ... existing migrations
AddDeleteAfterTransferColumn(),
AddCloudStorageFields(),
AddSkipProcessedFilesColumn(),
AddMaxConcurrentTransfersColumn(),
AddMultiConfigSupport(),
UpdateSkipProcessedFilesToNullable(),
AddWebhookSupport(),
AddGoogleDriveAuthenticated(),
AddGooglePhotosSupport(),
InitialSchema(),
UpdateBuiltinAuthFields(),
UpdateGDriveType(),
}
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
@@ -1,70 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// UpdateSkipProcessedFilesToNullable changes the skip_processed_files column to be nullable
func UpdateSkipProcessedFilesToNullable() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20250515_update_skip_processed_files_to_nullable",
Migrate: func(tx *gorm.DB) error {
// SQLite specific command - this would need to be adjusted for other databases
return tx.Exec("ALTER TABLE transfer_configs RENAME TO transfer_configs_old; " +
"CREATE TABLE transfer_configs (" +
"id INTEGER PRIMARY KEY AUTOINCREMENT, " +
"name VARCHAR(255) NOT NULL, " +
"source_type VARCHAR(255) NOT NULL, " +
"source_path VARCHAR(255) NOT NULL, " +
"source_host VARCHAR(255), " +
"source_port INTEGER DEFAULT 22, " +
"source_user VARCHAR(255), " +
"source_key_file VARCHAR(255), " +
"source_bucket VARCHAR(255), " +
"source_region VARCHAR(255), " +
"source_access_key VARCHAR(255), " +
"source_endpoint VARCHAR(255), " +
"source_share VARCHAR(255), " +
"source_domain VARCHAR(255), " +
"source_passive_mode BOOLEAN DEFAULT true, " +
"source_client_id VARCHAR(255), " +
"source_drive_id VARCHAR(255), " +
"source_team_drive VARCHAR(255), " +
"file_pattern VARCHAR(255) DEFAULT '*', " +
"output_pattern VARCHAR(255), " +
"destination_type VARCHAR(255) NOT NULL, " +
"destination_path VARCHAR(255) NOT NULL, " +
"dest_host VARCHAR(255), " +
"dest_port INTEGER DEFAULT 22, " +
"dest_user VARCHAR(255), " +
"dest_key_file VARCHAR(255), " +
"dest_bucket VARCHAR(255), " +
"dest_region VARCHAR(255), " +
"dest_access_key VARCHAR(255), " +
"dest_endpoint VARCHAR(255), " +
"dest_share VARCHAR(255), " +
"dest_domain VARCHAR(255), " +
"dest_passive_mode BOOLEAN DEFAULT true, " +
"dest_client_id VARCHAR(255), " +
"dest_drive_id VARCHAR(255), " +
"dest_team_drive VARCHAR(255), " +
"archive_path VARCHAR(255), " +
"archive_enabled BOOLEAN DEFAULT false, " +
"rclone_flags VARCHAR(255), " +
"delete_after_transfer BOOLEAN DEFAULT false, " +
"skip_processed_files BOOLEAN DEFAULT true, " + // Keep as BOOLEAN, but now it's nullable
"max_concurrent_transfers INTEGER DEFAULT 4, " +
"created_by INTEGER, " +
"created_at DATETIME, " +
"updated_at DATETIME" +
"); " +
"INSERT INTO transfer_configs SELECT * FROM transfer_configs_old; " +
"DROP TABLE transfer_configs_old;").Error
},
Rollback: func(tx *gorm.DB) error {
// No need to rollback as the data structure remains compatible
return nil
},
}
}
+4 -4
View File
@@ -72,7 +72,7 @@ func TestGenerateRcloneConfigWithoutRclone(t *testing.T) {
assert.NoError(t, err)
// Test configs for different source types
sourceTypes := []string{"sftp", "s3", "minio", "b2", "smb", "ftp", "webdav", "nextcloud", "onedrive", "google_drive"}
sourceTypes := []string{"sftp", "s3", "minio", "b2", "smb", "ftp", "webdav", "nextcloud", "onedrive", "gdrive"}
for _, sourceType := range sourceTypes {
testConfig := &TransferConfig{
@@ -102,7 +102,7 @@ func TestGenerateRcloneConfigWithoutRclone(t *testing.T) {
}
// Test configs for different destination types
destTypes := []string{"sftp", "s3", "minio", "b2", "smb", "ftp", "webdav", "nextcloud", "onedrive", "google_drive"}
destTypes := []string{"sftp", "s3", "minio", "b2", "smb", "ftp", "webdav", "nextcloud", "onedrive", "gdrive"}
for _, destType := range destTypes {
testConfig := &TransferConfig{
@@ -164,7 +164,7 @@ func TestGoogleDriveRcloneConfig(t *testing.T) {
// Create Google Drive source config
googleSourceConfig := &TransferConfig{
Name: "Google Drive Source Rclone Test",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "/path/in/google/drive",
SourceClientID: "source_google_client_id",
SourceClientSecret: "source_google_client_secret",
@@ -209,7 +209,7 @@ func TestGoogleDriveRcloneConfig(t *testing.T) {
Name: "Google Drive Dest Rclone Test",
SourceType: "local",
SourcePath: "/local/source/path",
DestinationType: "google_drive",
DestinationType: "gdrive",
DestinationPath: "/dest/path/in/google/drive",
DestClientID: "dest_google_client_id",
DestClientSecret: "dest_google_client_secret",
+15 -6
View File
@@ -71,6 +71,7 @@ func (h *Handlers) HandleEditConfig(c *gin.Context) {
// HandleCreateConfig handles the POST /configs route
func (h *Handlers) HandleCreateConfig(c *gin.Context) {
var config db.TransferConfig
if err := c.ShouldBind(&config); err != nil {
log.Printf("Error binding config form: %v", err)
c.String(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %v", err))
@@ -118,9 +119,13 @@ func (h *Handlers) HandleCreateConfig(c *gin.Context) {
sourceIncludeArchivedValue := sourceIncludeArchivedVal == "on" || sourceIncludeArchivedVal == "true"
config.SourceIncludeArchived = &sourceIncludeArchivedValue
useBuiltinAuthVal := c.Request.FormValue("use_builtin_auth")
useBuiltinAuthValue := useBuiltinAuthVal == "on" || useBuiltinAuthVal == "true"
config.UseBuiltinAuth = &useBuiltinAuthValue
useBuiltinAuthSourceVal := c.Request.FormValue("use_builtin_auth_source")
useBuiltinAuthSourceValue := useBuiltinAuthSourceVal == "on" || useBuiltinAuthSourceVal == "true"
config.UseBuiltinAuthSource = &useBuiltinAuthSourceValue
useBuiltinAuthDestVal := c.Request.FormValue("use_builtin_auth_dest")
useBuiltinAuthDestValue := useBuiltinAuthDestVal == "on" || useBuiltinAuthDestVal == "true"
config.UseBuiltinAuthDest = &useBuiltinAuthDestValue
if err := h.DB.Create(&config).Error; err != nil {
log.Printf("Error creating config: %v", err)
@@ -209,9 +214,13 @@ func (h *Handlers) HandleUpdateConfig(c *gin.Context) {
sourceIncludeArchivedValue := sourceIncludeArchivedVal == "on" || sourceIncludeArchivedVal == "true"
config.SourceIncludeArchived = &sourceIncludeArchivedValue
useBuiltinAuthVal := c.Request.FormValue("use_builtin_auth")
useBuiltinAuthValue := useBuiltinAuthVal == "on" || useBuiltinAuthVal == "true"
config.UseBuiltinAuth = &useBuiltinAuthValue
useBuiltinAuthSourceVal := c.Request.FormValue("use_builtin_auth_source")
useBuiltinAuthSourceValue := useBuiltinAuthSourceVal == "on" || useBuiltinAuthSourceVal == "true"
config.UseBuiltinAuthSource = &useBuiltinAuthSourceValue
useBuiltinAuthDestVal := c.Request.FormValue("use_builtin_auth_dest")
useBuiltinAuthDestValue := useBuiltinAuthDestVal == "on" || useBuiltinAuthDestVal == "true"
config.UseBuiltinAuthDest = &useBuiltinAuthDestValue
// Preserve fields that shouldn't be updated
config.CreatedBy = oldConfig.CreatedBy
+3 -3
View File
@@ -37,7 +37,7 @@ func (h *Handlers) HandleGDriveAuth(c *gin.Context) {
}
// Ensure it's a Google Drive or Google Photos configuration
if config.DestinationType != "gdrive" && config.DestinationType != "gphotos" {
if config.SourceType != "gdrive" && config.DestinationType != "gdrive" && config.SourceType != "gphotos" && config.DestinationType != "gphotos" {
RenderErrorPage(c, "Not a Google configuration", "The selected configuration is not set up for Google Drive or Google Photos")
return
}
@@ -365,8 +365,8 @@ func (h *Handlers) HandleGDriveTokenProcess(c *gin.Context) {
return
}
// Ensure it's a Google Drive configuration
if config.DestinationType != "gdrive" {
// Ensure it's a Google Drive or Google Photos configuration
if config.SourceType != "gdrive" && config.DestinationType != "gdrive" && config.SourceType != "gphotos" && config.DestinationType != "gphotos" {
RenderErrorPage(c, "Not a Google Drive configuration", "")
return
}
+14 -14
View File
@@ -64,26 +64,26 @@ func (h *MockHandlers) HandleGDriveAuth(c *gin.Context) {
// Get the config ID from the query parameter
configIDStr := c.Param("id")
if configIDStr == "" {
RenderErrorPage(c, "Missing configuration ID", "")
RenderErrorPageTest(c, "Missing configuration ID", "")
return
}
configID, err := strconv.ParseUint(configIDStr, 10, 64)
if err != nil {
RenderErrorPage(c, "Invalid configuration ID", err.Error())
RenderErrorPageTest(c, "Invalid configuration ID", err.Error())
return
}
// Get the configuration
config, err := h.DB.GetTransferConfig(uint(configID))
if err != nil {
RenderErrorPage(c, "Configuration not found", err.Error())
RenderErrorPageTest(c, "Configuration not found", err.Error())
return
}
// Ensure it's a Google Drive or Google Photos configuration
if config.DestinationType != "gdrive" && config.DestinationType != "gphotos" {
RenderErrorPage(c, "Not a Google configuration", "The selected configuration is not set up for Google Drive or Google Photos")
RenderErrorPageTest(c, "Not a Google configuration", "The selected configuration is not set up for Google Drive or Google Photos")
return
}
@@ -96,14 +96,14 @@ func (h *MockHandlers) HandleGDriveAuth(c *gin.Context) {
// Get Rclone Config Path
rcloneConfigPath := h.DB.GetConfigRclonePath(config)
if rcloneConfigPath == "" {
RenderErrorPage(c, "Rclone config not found", "The selected configuration does not have a valid rclone config")
RenderErrorPageTest(c, "Rclone config not found", "The selected configuration does not have a valid rclone config")
return
}
// Create a temporary config file for authentication
tempConfigDir := filepath.Join(dataDir, "temp")
if err := os.MkdirAll(tempConfigDir, 0755); err != nil {
RenderErrorPage(c, "Failed to create temporary directory", err.Error())
RenderErrorPageTest(c, "Failed to create temporary directory", err.Error())
return
}
@@ -180,7 +180,7 @@ func (h *MockHandlers) HandleGDriveAuthCallback(c *gin.Context) {
// Get auth code from query parameters
authCode := c.Query("code")
if authCode == "" {
RenderErrorPage(c, "Authentication failed", "No authorization code received from Google")
RenderErrorPageTest(c, "Authentication failed", "No authorization code received from Google")
return
}
@@ -188,27 +188,27 @@ func (h *MockHandlers) HandleGDriveAuthCallback(c *gin.Context) {
state := c.Query("state")
storedState, err := c.Cookie("gdrive_auth_state")
if err != nil || state != storedState {
RenderErrorPage(c, "Authentication failed", "Invalid state parameter")
RenderErrorPageTest(c, "Authentication failed", "Invalid state parameter")
return
}
// Get config ID from cookie
configIDStr, err := c.Cookie("gdrive_config_id")
if err != nil {
RenderErrorPage(c, "Authentication failed", "Unable to retrieve configuration ID")
RenderErrorPageTest(c, "Authentication failed", "Unable to retrieve configuration ID")
return
}
configID, err := strconv.ParseUint(configIDStr, 10, 64)
if err != nil {
RenderErrorPage(c, "Invalid configuration ID", err.Error())
RenderErrorPageTest(c, "Invalid configuration ID", err.Error())
return
}
// Get the configuration
config, err := h.DB.GetTransferConfig(uint(configID))
if err != nil {
RenderErrorPage(c, "Failed to get configuration", err.Error())
RenderErrorPageTest(c, "Failed to get configuration", err.Error())
return
}
@@ -219,7 +219,7 @@ func (h *MockHandlers) HandleGDriveAuthCallback(c *gin.Context) {
// Update the config with the token
err = h.DB.GenerateRcloneConfigWithToken(config, mockToken)
if err != nil {
RenderErrorPage(c, "Failed to update configuration", err.Error())
RenderErrorPageTest(c, "Failed to update configuration", err.Error())
return
}
@@ -420,8 +420,8 @@ func TestHandleGDriveAuth_NonGoogleConfig(t *testing.T) {
assert.Contains(t, w.Body.String(), "Not a Google configuration")
}
// RenderErrorPage renders an error page with the given message
func RenderErrorPage(c *gin.Context, title string, details string) {
// RenderErrorPageTest renders an error page with the given message
func RenderErrorPageTest(c *gin.Context, title string, details string) {
// Here we'd typically use a component for error display
// For now, we'll just render a simple HTML error page for testing
errorHTML := fmt.Sprintf("<html><body><h1>Error: %s</h1><p>%s</p></body></html>", title, details)
+76
View File
@@ -0,0 +1,76 @@
package handlers
import (
"net/http"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
)
// HandleCheckPath validates if a given path exists and is accessible
func (h *Handlers) HandleCheckPath(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{
"valid": false,
"error": "No path provided",
})
return
}
// Clean and resolve the path
path = filepath.Clean(path)
absPath, err := filepath.Abs(path)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"valid": false,
"error": "Invalid path format",
})
return
}
// Check if path exists
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusOK, gin.H{
"valid": false,
"error": "Path does not exist",
})
return
}
c.JSON(http.StatusOK, gin.H{
"valid": false,
"error": "Error accessing path: " + err.Error(),
})
return
}
// Check if it's a directory
if !info.IsDir() {
c.JSON(http.StatusOK, gin.H{
"valid": false,
"error": "Path exists but is not a directory",
})
return
}
// Check if we have read access
testFile := filepath.Join(absPath, ".gomft_test")
f, err := os.OpenFile(testFile, os.O_CREATE|os.O_WRONLY, 0666)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"valid": false,
"error": "Directory exists but is not writable",
})
return
}
f.Close()
os.Remove(testFile)
c.JSON(http.StatusOK, gin.H{
"valid": true,
"error": "",
})
}
+3
View File
@@ -32,6 +32,9 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
authorized.POST("/configs/:id", h.HandleUpdateConfig)
authorized.DELETE("/configs/:id", h.HandleDeleteConfig)
// Path validation endpoint
authorized.GET("/check-path", h.HandleCheckPath)
// Google Drive authentication routes
authorized.GET("/configs/:id/gdrive-auth", h.HandleGDriveAuth)
authorized.GET("/configs/gdrive-callback", h.HandleGDriveAuthCallback)
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+450
View File
@@ -0,0 +1,450 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="113.424mm"
height="135.52705mm"
viewBox="0 0 113.424 135.52705"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
sodipodi:docname="gomft_golpher.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="true"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1"
inkscape:cx="182"
inkscape:cy="298"
inkscape:window-width="1512"
inkscape:window-height="831"
inkscape:window-x="0"
inkscape:window-y="38"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
showborder="true" /><defs
id="defs1"><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath3542"><rect
style="fill:#0000ff;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="rect3544"
width="1093.424"
height="1518.2074"
x="-1174.7097"
y="-295.40738" /></clipPath></defs><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-48.260679,-4.7323055)"
style="display:inline"><g
style="enable-background:new"
id="g1"
transform="matrix(0.28222222,0,0,0.28222222,43.26068,-0.26769346)"
inkscape:label="golpher"><g
id="layer11"
inkscape:label="background"
style="display:inline"
transform="translate(-159.49345,-229.70978)" /><g
id="layer5"
inkscape:label="gopher-body"
style="display:inline;opacity:1"
transform="matrix(0.99955497,0,0,0.99955497,-159.32544,-229.49282)"><g
id="g4815"
transform="matrix(-0.32879267,0.17361606,0.20143296,0.28338802,93.068619,282.82497)"
style="opacity:1"><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#96d6ff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m -626.54672,402.3529 c 2.22767,10.86299 0.34493,21.82632 -3.86747,31.42527 -4.21252,9.59894 -10.55173,17.86115 -17.72096,24.29983 -7.1694,6.43883 -15.25476,11.10591 -24.5716,13.61353 -9.31698,2.50761 -20.94966,4.46936 -31.63903,1.98398 -10.68939,-2.48537 -18.0688,-9.22838 -24.09401,-15.89285 -6.02508,-6.66442 -12.35923,-14.47524 -22.96531,-22.06805 -10.60584,-7.59266 -20.8648,-15.59839 -25.16123,-23.3775 -4.29632,-7.77931 -7.008,-15.66934 -7.81517,-23.39095 -0.80717,-7.7215 0.35908,-14.55922 3.12288,-20.54462 2.76393,-5.98548 7.12557,-11.1208 12.7854,-15.40902 5.65998,-4.28811 12.61751,-7.73606 20.64204,-10.24271 8.02465,-2.50651 17.11262,-4.07552 27.13941,-4.41504 10.0268,-0.3395 20.06604,0.59388 29.76158,2.87504 9.69543,2.2813 19.05511,5.92037 27.47739,11.02309 8.42215,5.10286 15.89307,11.69212 21.60465,19.6287 5.71147,7.93674 13.0738,19.62846 15.30143,30.4913 z"
id="path4806"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" /><path
transform="matrix(13.851095,0,0,13.851095,10133.213,-6001.611)"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m -784.21409,457.33922 c -0.56136,0.0656 -1.08141,0.1809 -1.55606,0.33615 -0.63289,0.20699 -1.18396,0.48516 -1.6349,0.82686 -0.45093,0.3417 -0.80184,0.74659 -1.02778,1.21891 -0.22595,0.47234 -0.32669,1.01119 -0.27449,1.62035 0.0522,0.60917 0.25282,1.23371 0.57968,1.84938 0.32687,0.61567 0.98957,1.25218 1.83531,1.84156 0.84574,0.58937 1.35671,1.20529 1.82543,1.72857 0.46713,0.52147 1.13451,0.85371 2.02424,0.92674 0.10253,0.008 0.12328,-0.30471 0.0344,-0.32876 -0.78083,-0.20262 -1.25826,-0.72023 -1.71877,-1.11076 -0.4254,-0.46645 -0.87231,-1.01406 -1.62104,-1.54604 -0.74871,-0.53197 -1.47289,-1.09304 -1.77689,-1.63886 -0.30398,-0.54584 -0.49685,-1.10009 -0.55469,-1.64239 -0.0579,-0.54231 0.0245,-1.0222 0.21918,-1.44322 0.19469,-0.42103 0.50198,-0.78371 0.90168,-1.08623 0.39973,-0.30252 0.89062,-0.54587 1.4577,-0.7237 0.28355,-0.0889 0.5872,-0.16119 0.90722,-0.21465 0.32002,-0.0535 0.6576,-0.0885 1.01178,-0.10163 0.70839,-0.0255 1.4163,0.0392 2.10043,0.1987 0.68412,0.15947 1.34499,0.41522 1.93838,0.77329 0.59338,0.35806 1.11885,0.81986 1.52108,1.37653 0.40222,0.55667 0.92117,1.37523 1.07925,2.13677 0.12981,0.62539 0.0734,1.25844 -0.13288,1.83379 -0.0385,0.10712 0.4977,0.29416 0.62787,-0.0111 0.24265,-0.5698 0.23445,-1.24057 0.1026,-1.8741 -0.17834,-0.85666 -0.69031,-1.76937 -1.13671,-2.40019 -0.4464,-0.6308 -1.03123,-1.15292 -1.68895,-1.55276 -0.65772,-0.39984 -1.38674,-0.68003 -2.14271,-0.85021 -0.75599,-0.17016 -1.54036,-0.23166 -2.32498,-0.19142 -0.19617,0.0101 -0.38815,0.0268 -0.57528,0.0484 z"
id="path4808"
inkscape:connector-curvature="0"
sodipodi:nodetypes="csssccscsccscscccsccscsssscscscc" /><path
sodipodi:nodetypes="cssscsssc"
inkscape:connector-curvature="0"
id="path4810"
d="m -753.77185,413.0219 c -0.13663,-2.61847 2.18018,-4.94804 7.2193,-6.20054 7.65443,-1.90257 20.03831,1.84566 27.93811,5.67152 4.33357,2.09883 8.88981,3.89076 12.66635,7.19411 1.28185,1.12133 2.51799,2.28349 3.36855,4.40869 -1.65849,0.577 -4.10492,-0.92134 -5.87278,-2.13046 -6.96771,-4.76531 -14.69502,-8.08983 -22.67695,-9.12646 -6.71591,-0.87187 -8.86923,-3.11022 -14.75541,-2.56175 -3.72583,0.34716 -4.90626,2.13878 -7.88716,2.74489 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><path
sodipodi:nodetypes="sssssssss"
inkscape:connector-curvature="0"
id="path4813"
d="m -720.16989,411.68353 c 0.28532,-2.32502 0.86962,3.90377 -0.31886,5.45995 -4.46007,5.84 -8.20289,12.32072 -12.42083,18.36519 -1.37385,1.96787 -3.29463,0.0414 -2.42738,-2.09874 0.88118,-2.1739 2.06053,-3.99898 3.34915,-5.8153 1.20809,-1.70147 2.81353,-3.0576 3.88834,-4.85958 2.06619,-3.46267 2.39577,-6.62873 4.25443,-10.2393 0.63712,-1.23818 3.5225,0.42546 3.67386,-0.80905 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /></g><g
style="display:inline;enable-background:new"
id="g4710"
transform="matrix(-0.69719495,0.22290406,0.41767232,0.92198706,562.8644,-101.63567)"><path
sodipodi:nodetypes="sssssssssssssssss"
inkscape:connector-curvature="0"
id="path4706"
d="m 423.50332,581.83521 c -0.004,4.40048 -1.19837,7.58856 -3.37524,9.82844 -2.17687,2.23987 -5.33154,3.55156 -9.14619,4.44292 -3.81465,0.89135 -8.28246,1.39523 -13.05675,1.83828 -4.77428,0.44304 -9.85163,0.79076 -14.95001,1.09928 -5.09838,0.30851 -9.94541,0.34741 -14.40217,0.0862 -4.45676,-0.26122 -8.52354,-0.79908 -11.99271,-1.71189 -3.46915,-0.91282 -6.33736,-2.21356 -8.3562,-4.09288 -2.01885,-1.87935 -3.18709,-4.34475 -3.25466,-7.51083 -0.0676,-3.16607 0.9983,-5.4859 2.92534,-7.0838 1.92703,-1.5979 4.71248,-2.46394 8.09977,-2.84688 3.38729,-0.38293 7.37282,-0.28336 11.77044,-0.16051 4.39762,0.12284 9.21051,0.23456 14.33166,-0.12202 5.12115,-0.35659 10.27171,-1.47349 15.16022,-2.54099 4.88852,-1.06749 9.50395,-2.05149 13.43823,-2.27114 3.9343,-0.21967 7.17754,0.32322 9.39823,2.04598 2.22069,1.72276 3.41425,4.59936 3.41004,8.99986 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#e1d6b9;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><path
sodipodi:nodetypes="ssscsscssssssssssssssssssssccsss"
inkscape:connector-curvature="0"
id="path4708"
d="m 411.91406,568.54883 c -3.75011,-0.0271 -8.08701,0.53975 -12.76172,1.28711 -5.34251,0.85413 -11.10706,1.92059 -17.00976,2.32617 -5.9027,0.40562 -11.41103,0.38326 -16.44727,0.41406 -5.03624,0.0309 -9.6045,0.1607 -13.50781,0.85938 -3.9033,0.69867 -7.13503,1.96743 -9.4082,3.96875 -2.27316,2.00131 -3.58535,4.71676 -3.65235,8.17578 -0.067,3.45901 1.21821,6.3073 3.54297,8.58008 2.32476,2.27278 5.68789,3.9795 9.76172,5.25 4.07385,1.27051 8.85237,2.11894 14.05664,2.59765 5.20427,0.47871 10.83381,0.56134 16.70313,0.22266 5.86931,-0.33868 11.47146,-0.78653 16.60547,-1.34961 5.13399,-0.56309 9.79334,-1.22365 13.70703,-2.34375 1.48913,-0.4262 2.86677,-0.9287 4.12695,-1.51953 2.54507,-1.19325 2.05015,-6.17249 -0.0996,-4.54102 -1.99172,1.51153 -4.55969,2.50355 -7.57031,3.20703 -3.66893,0.85731 -7.96668,1.34146 -12.5586,1.76758 -4.59191,0.42612 -9.47527,0.75991 -14.3789,1.05664 -4.90363,0.29673 -9.56506,0.33523 -13.85156,0.084 -4.28652,-0.25124 -8.19851,-0.76855 -11.53516,-1.64649 -3.33664,-0.87795 -6.09539,-2.12996 -8.03711,-3.9375 -1.94173,-1.80756 -3.06587,-4.17751 -3.13086,-7.22265 -0.065,-3.04513 0.96102,-5.2776 2.81445,-6.81446 1.85342,-1.53686 4.53117,-2.36997 7.78907,-2.73828 3.2579,-0.36831 7.09262,-0.27244 11.32226,-0.1543 4.22963,0.11816 8.85767,0.22578 13.7832,-0.11718 4.92553,-0.34297 9.88026,-1.41664 14.58204,-2.44336 4.70178,-1.02671 9.13982,-1.97234 12.92382,-2.1836 0.473,-0.0264 0.93707,-0.0422 1.38868,-0.0449 1.16046,-0.007 2.25007,0.0442 3.25,0.23633 1.15313,0.22156 2.31543,-2.86146 -0.83789,-2.92773 -0.51177,-0.0108 -1.03459,-0.045 -1.57032,-0.0488 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /></g><g
style="display:inline;opacity:1"
id="g4682"
transform="matrix(-0.11933103,0.60316395,1.3605999,0.30916083,88.824519,44.52189)"><path
sodipodi:nodetypes="sssssssssssssssss"
inkscape:connector-curvature="0"
id="path4678"
d="m 767.29926,387.32674 c 11.1235,7.96555 31.77795,11.29978 44.73159,15.54502 12.95363,4.24526 18.14889,9.35948 22.12936,13.37285 3.98046,4.01338 5.94428,7.14463 4.71807,9.52723 -1.2262,2.38259 -5.54351,3.99405 -14.00119,4.81166 -8.45765,0.81761 -15.90978,0.12055 -23.02358,-1.72572 -7.11381,-1.84628 -13.80694,-4.86649 -21.70559,-8.603 -7.89866,-3.73649 -17.3272,-8.0507 -25.81115,-14.18439 -8.48395,-6.13369 -17.62324,-13.90003 -23.14238,-24.13356 -5.51915,-10.23352 -5.78201,-21.34406 -5.37146,-30.88264 0.41055,-9.53859 1.51092,-17.55377 2.71572,-23.74931 1.20482,-6.19553 2.71509,-10.67437 4.77102,-13.66952 2.05591,-2.99513 4.65165,-4.52673 7.71923,-4.52673 3.06759,0 5.70357,1.83092 7.62535,5.49926 1.9218,3.66832 3.04778,9.24444 3.28639,16.76004 0.23861,7.51561 -0.67126,17.08072 0.34029,27.19831 1.01155,10.1176 3.89485,20.79494 15.01833,28.7605 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><path
sodipodi:nodetypes="sssssssssssssssss"
inkscape:connector-curvature="0"
id="path4680"
d="m 760.81735,387.61463 c 8.35351,7.22933 23.40419,11.34465 36.92829,14.85447 13.52408,3.50986 21.76315,7.50998 26.41399,11.29491 4.65086,3.78492 7.04347,6.96136 6.89289,9.28045 -0.15059,2.31908 -3.07202,3.85186 -9.99413,4.53735 -6.92209,0.68549 -13.12478,-0.17957 -19.18856,-2.15841 -6.06375,-1.97886 -12.01277,-5.06603 -19.62326,-8.64782 -7.61047,-3.5818 -16.94465,-7.61787 -24.98938,-13.21535 -8.04472,-5.59749 -15.82286,-12.65396 -20.9022,-21.24583 -5.07935,-8.59186 -6.01346,-17.801 -5.99188,-25.91871 0.0216,-8.1177 0.93462,-15.14861 1.86635,-20.66954 0.93173,-5.52092 2.01706,-9.59713 3.38259,-12.30465 1.36554,-2.70753 3.03466,-4.06947 5.01979,-4.01398 1.98511,0.0555 3.57672,1.84704 4.61437,5.2751 1.03765,3.42807 1.44745,8.54444 1.4737,15.15288 0.0262,6.60845 -0.43638,14.76057 0.91317,23.27473 1.34954,8.51418 4.83074,17.27506 13.18427,24.5044 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#e1d6b9;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /></g><g
style="display:inline"
id="g4533-2"
transform="matrix(-0.60102903,0.32221978,0.53870829,0.77401445,526.12645,47.501077)" /><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.42763;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 367.21812,281.2111 c 1.26906,4.8e-4 2.53948,0.0178 3.81226,0.0513 42.77724,1.12555 90.70354,5.69495 122.10388,40.54871 32.84028,36.45207 50.86245,87.73981 42.4768,134.73998 -3.70559,20.76925 -10.98111,49.47352 -17.74195,62.40674 -7.11403,13.60881 -19.80467,12.64104 -20.03271,14.6001 -0.95849,8.23388 28.65775,0.0351 45.92102,17.56348 14.06339,14.27947 18.47143,40.7184 18.81226,59.65026 0.0821,4.5609 -0.0281,9.27804 -0.3772,14.04236 -14.49094,35.53359 -47.68736,57.07915 -104.82056,72.23694 -16.35254,4.33843 -32.98332,6.84124 -49.34143,8.0127 -33.94749,-1.39556 -67.79597,-6.13242 -100.13854,-17.26685 -32.65579,-11.24226 -56.145,-40.89976 -74.77295,-73.88489 -29.47508,-52.19252 -35.755,-116.16519 -27.31567,-176.46789 7.06058,-50.45097 11.6574,-86.98057 52.22351,-116.77185 32.34621,-23.75469 69.85025,-39.47592 109.19128,-39.46106 z"
id="path4609"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cssssccscscsssssc" /><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#96d6ff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.42763;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 367.42685,286.88188 c 42.99236,0.29098 91.45399,4.58065 122.97913,39.77783 29.3149,32.72949 43.70846,77.17043 40.47912,119.64477 -1.85014,24.33459 -4.2527,46.89446 -15.33809,68.52471 -7.40578,14.45044 -25.66197,16.94779 -25.75077,21.45698 -0.0598,3.03823 12.8172,2.29092 36.45996,9.33838 18.23589,5.43578 21.08736,15.45128 27.9657,34.84863 3.54426,9.99504 3.63562,21.40294 3.86353,32.07459 0.0987,4.62319 -0.16021,9.28578 -0.71778,13.93066 -15.76071,31.8161 -48.31041,51.69261 -101.98425,65.93262 -19.90398,5.28064 -40.22223,7.84987 -59.95606,8.5968 -23.42101,-1.57782 -46.67598,-5.15666 -69.29992,-11.41663 -36.53114,-10.10803 -65.44663,-39.43363 -86.5155,-75.75622 -29.60753,-51.04315 -36.18916,-113.60515 -29.37012,-173.2251 5.79548,-50.67089 11.02944,-86.89891 51.97998,-115.98999 31.47751,-22.36148 67.20785,-37.99521 105.20507,-37.73803 z"
id="path4611"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssscssccscsssss" /><path
style="opacity:1;fill:#394655;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.633166"
d="m 550.04547,566.15505 c -8.01527,-17.95204 -33.2969,-29.54939 -59.90815,-26.35079 -36.4344,4.37932 -107.00869,29.17307 -108.83839,27.45259 32.95965,-15.3268 80.70019,-26.25455 112.65433,-35.45314 11.18054,-3.21853 19.35455,-9.69456 24.10347,-20.91405 0,0 -4.41927,9.99274 -4.75073,10.24133 -0.33145,0.24859 -7.78922,5.96622 -7.78922,5.96622 0,0 -6.79486,3.48029 -7.29204,3.72887 -0.49719,0.2486 -4.80611,3.39744 -4.80611,3.39744 0,0 2.15446,1.57441 2.9831,1.823 0.82864,0.24859 2.98311,1.65729 4.64039,1.82301 1.65728,0.16573 7.12631,0.66292 11.93243,1.4087 4.80611,0.74577 15.08126,3.23169 17.48432,4.39179 2.40305,1.16009 7.62349,4.06034 7.87208,4.47466 0.2486,0.41432 11.71452,18.01037 11.71452,18.01037 z"
id="path4629"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cscscccccsccccc" /><g
style="display:inline;opacity:1"
transform="matrix(-0.19755642,-0.64741346,0.92085299,-0.16781481,-30.330781,1045.2275)"
id="g4674"><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#e1d6b9;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 423.50332,581.83521 c -0.004,4.40048 -1.19837,7.58856 -3.37524,9.82844 -2.17687,2.23987 -5.33154,3.55156 -9.14619,4.44292 -3.81465,0.89135 -8.28246,1.39523 -13.05675,1.83828 -4.77428,0.44304 -9.85163,0.79076 -14.95001,1.09928 -5.09838,0.30851 -9.94541,0.34741 -14.40217,0.0862 -4.45676,-0.26122 -8.52354,-0.79908 -11.99271,-1.71189 -3.46915,-0.91282 -6.33736,-2.21356 -8.3562,-4.09288 -2.01885,-1.87935 -3.18709,-4.34475 -3.25466,-7.51083 -0.0676,-3.16607 0.9983,-5.4859 2.92534,-7.0838 1.92703,-1.5979 4.71248,-2.46394 8.09977,-2.84688 3.38729,-0.38293 7.37282,-0.28336 11.77044,-0.16051 4.39762,0.12284 9.21051,0.23456 14.33166,-0.12202 5.12115,-0.35659 10.27171,-1.47349 15.16022,-2.54099 4.88852,-1.06749 9.50395,-2.05149 13.43823,-2.27114 3.9343,-0.21967 7.17754,0.32322 9.39823,2.04598 2.22069,1.72276 3.41425,4.59936 3.41004,8.99986 z"
id="path4670"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" /><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 411.91406,568.54883 c -3.75011,-0.0271 -8.08701,0.53975 -12.76172,1.28711 -5.34251,0.85413 -11.10706,1.92059 -17.00976,2.32617 -5.9027,0.40562 -11.41103,0.38326 -16.44727,0.41406 -5.03624,0.0309 -9.6045,0.1607 -13.50781,0.85938 -3.9033,0.69867 -7.13503,1.96743 -9.4082,3.96875 -2.27316,2.00131 -3.58535,4.71676 -3.65235,8.17578 -0.067,3.45901 1.21821,6.3073 3.54297,8.58008 2.32476,2.27278 5.68789,3.9795 9.76172,5.25 4.07385,1.27051 8.85237,2.11894 14.05664,2.59765 5.20427,0.47871 10.83381,0.56134 16.70313,0.22266 5.86931,-0.33868 11.47146,-0.78653 16.60547,-1.34961 5.13399,-0.56309 9.79334,-1.22365 13.70703,-2.34375 1.48913,-0.4262 2.86677,-0.9287 4.12695,-1.51953 2.54507,-1.19325 2.05015,-6.17249 -0.0996,-4.54102 -1.99172,1.51153 -4.14364,1.68162 -7.15735,2.35061 -3.67269,0.81527 -8.18136,0.99111 -12.55008,1.3428 -4.3687,0.35167 -8.7789,1.78431 -13.31332,2.07736 -4.53444,0.29304 -8.86787,0.32801 -12.93181,0.0702 -4.06396,-0.25785 -7.85651,-0.78075 -11.12475,-1.64296 -3.26823,-0.86221 -5.99695,-2.08037 -7.8846,-3.81399 -1.88765,-1.73365 -2.92537,-3.9871 -2.97865,-6.80086 -0.0533,-2.81374 0.90176,-4.8192 2.66881,-6.10562 1.76704,-1.28641 5.61732,-0.58475 8.69196,-0.71399 3.07463,-0.12925 6.90624,-0.54484 10.78772,-0.41733 3.88147,0.12754 6.54592,-0.48119 11.04844,-1.2139 4.50252,-0.73264 9.15212,-2.3434 13.88736,-3.72101 4.73523,-1.37761 9.22461,-2.34259 13.00861,-2.55385 0.473,-0.0264 0.93707,-0.0422 1.38868,-0.0449 1.16046,-0.007 2.25007,0.0442 3.25,0.23633 1.15313,0.22156 2.31543,-2.86146 -0.83789,-2.92773 -0.51177,-0.0108 -1.03459,-0.045 -1.57032,-0.0488 z"
id="path4672"
inkscape:connector-curvature="0"
sodipodi:nodetypes="csscsscssssssssssssssssssssccsssc" /></g><g
style="opacity:1"
transform="matrix(0.38329319,-0.18164886,0.18164886,0.38329319,415.72605,54.494616)"
id="g4804"><path
sodipodi:nodetypes="sssssssssssssssss"
inkscape:connector-curvature="0"
id="path4796"
d="m -626.57295,401.69566 c 2.24713,11.35067 0.36741,22.38948 -3.843,32.03835 -4.21053,9.64886 -10.54997,17.90531 -17.7192,24.34399 -7.1694,6.43883 -15.25457,11.1106 -24.57171,13.61082 -9.31727,2.5002 -20.94956,4.47176 -31.64526,1.82793 -10.69571,-2.64383 -18.09209,-9.81214 -24.14818,-17.25062 -6.05597,-7.43843 -12.44269,-16.56671 -23.09665,-25.35944 -10.65372,-8.79255 -20.95218,-17.78817 -25.30072,-26.87318 -4.34843,-9.08528 -7.1154,-18.36084 -7.98,-27.52156 -0.86459,-9.1606 0.24716,-17.36404 2.9617,-24.58398 2.71467,-7.22004 7.03243,-13.45488 12.66059,-18.5369 5.6283,-5.08191 12.56665,-9.01064 20.59229,-11.48936 8.02576,-2.47858 17.13537,-3.50537 27.20916,-2.66707 10.0738,0.83832 20.1809,3.47234 29.95223,7.6529 9.77122,4.18068 19.21426,9.9086 27.71179,16.89733 8.49741,6.98886 16.03465,15.24007 21.79567,24.41557 5.7609,9.17565 13.1742,22.14471 15.42129,33.49522 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#96d6ff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><path
transform="matrix(13.851095,0,0,13.851095,10133.213,-6001.611)"
sodipodi:nodetypes="csssccscsccscscccsccscsssscscscc"
inkscape:connector-curvature="0"
id="path4798"
d="m -784.27135,455.90422 c -0.56339,0.0147 -1.08437,0.10666 -1.55902,0.26191 -0.63289,0.20699 -1.18231,0.52669 -1.63059,0.93484 -0.44828,0.40815 -0.79558,0.90361 -1.01756,1.4752 -0.22199,0.5716 -0.31844,1.21792 -0.26185,1.93717 0.0566,0.71926 0.26134,1.4471 0.59196,2.157 0.33063,0.7099 0.99621,1.41858 1.84494,2.08284 0.84872,0.66425 1.36325,1.36931 1.83382,1.93901 0.46898,0.56774 1.13678,0.9105 2.02675,0.98962 0.10256,0.009 0.12294,-0.31321 0.034,-0.33899 -0.78143,-0.21746 -1.26048,-0.77583 -1.72293,-1.21489 -0.42768,-0.5236 -0.87838,-1.16625 -1.63058,-1.78505 -0.75217,-0.61879 -1.47924,-1.25213 -1.78697,-1.89162 -0.30772,-0.63951 -0.50455,-1.29287 -0.56648,-1.9378 -0.062,-0.64492 0.0165,-1.22191 0.20772,-1.73042 0.1912,-0.50852 0.49539,-0.94884 0.89287,-1.30706 0.3975,-0.35822 0.88707,-0.63484 1.45426,-0.80994 0.2836,-0.0875 0.58767,-0.1494 0.90851,-0.1822 0.32084,-0.0328 0.65966,-0.0369 1.01552,-0.008 0.71174,0.0585 1.42446,0.24383 2.11396,0.53794 0.6895,0.29412 1.35628,0.69807 1.95502,1.19025 0.59873,0.49218 1.12894,1.07271 1.53474,1.71893 0.4058,0.64623 0.9285,1.5589 1.08808,2.35795 0.13104,0.65619 0.075,1.29927 -0.13103,1.88026 -0.0384,0.10817 0.49808,0.30362 0.62824,-0.002 0.24262,-0.57052 0.23429,-1.24452 0.10166,-1.89748 -0.17938,-0.88293 -0.69436,-1.871 -1.14416,-2.58711 -0.44981,-0.71609 -1.03943,-1.35821 -1.70275,-1.89855 -0.66333,-0.54034 -1.3987,-0.97968 -2.16052,-1.29649 -0.76184,-0.31679 -1.55154,-0.51173 -2.33984,-0.56369 -0.19709,-0.013 -0.38986,-0.0163 -0.57767,-0.0116 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><path
sodipodi:nodetypes="cssscsssc"
inkscape:connector-curvature="0"
id="path4800"
d="m -730.27274,382.91266 c 1.8068,-2.76405 6.31309,-3.63001 13.24575,-1.6171 10.53068,3.05761 22.43414,14.97755 28.94834,24.04709 3.57338,4.97534 7.6424,9.78266 9.64772,15.62449 0.68055,1.98294 1.27611,3.97774 0.68898,6.70435 -2.4056,-0.49416 -4.1871,-3.62313 -5.37952,-6.01329 -4.69962,-9.4202 -11.38574,-17.86492 -20.09536,-24.13889 -7.3284,-5.27852 -8.20487,-8.9719 -15.61502,-12.25742 -4.69053,-2.07967 -7.44128,-1.02076 -11.44089,-2.34923 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><path
sodipodi:nodetypes="sssssssss"
inkscape:connector-curvature="0"
id="path4802"
d="m -689.31909,403.49962 c 2.08771,-2.1886 -1.9021,4.5559 -4.48533,5.36905 -9.69439,3.05157 -19.01784,7.22624 -28.57811,10.64488 -3.11327,1.11257 -3.94795,-2.11026 -1.30738,-3.72982 2.68251,-1.64492 5.45711,-2.73872 8.35507,-3.75217 2.71578,-0.94874 5.64428,-1.2851 8.27731,-2.4236 5.06052,-2.18718 7.83343,-5.20599 12.75841,-7.67984 1.68866,-0.84854 3.86766,2.73608 4.97603,1.5739 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /></g></g><g
id="layer6"
inkscape:label="gopher-body-shadow"
style="opacity:0.07"
transform="translate(-22.468606,-107.81499)"><path
style="opacity:1;fill:#1e1e1e;fill-opacity:1;stroke:none;stroke-width:3.53553;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 410.73438,402.67383 c -22.12212,5.91178 -47.244,11.63106 -70.61524,16.37305 -48.79037,9.89949 -67.17652,36.06247 -78.49023,54.44726 l 6.87695,-1.67969 c 36.64463,-15.30695 75.75822,-23.23183 113.48437,-35.26953 12.09814,-3.47331 21.06002,-13.5287 25.50391,-25.00976 1.18561,-2.92243 2.25783,-5.87876 3.24024,-8.86133 z"
transform="scale(0.93749999)"
id="path4829"
inkscape:connector-curvature="0" /><path
style="opacity:1;fill:#1e1e1e;fill-opacity:1;stroke:none;stroke-width:5;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 176.74023,191.19336 c -12.02297,5.35584 -23.55765,11.8602 -34.44531,19.05273 -5.41851,4.11086 -12.04772,7.05917 -16.45703,12.31055 0.0894,0.61993 -0.23486,1.1597 -0.71484,1.54883 -0.66228,4.1883 -1.36755,8.37707 -2.08594,12.50781 -5.65684,32.5269 -33.941548,3.53554 -50.91211,0 -10.52665,-2.19305 -19.680537,-11.20026 -16.179688,-32.73437 -1.579179,1.23479 -3.051377,2.63841 -4.392578,4.22461 -5.278878,6.02984 -6.059769,15.29458 -3.236328,22.56836 4.226081,7.59968 10.487816,15.22544 19.425782,17.11523 7.922144,1.96176 15.28729,5.43641 22.820312,8.4375 3.386589,1.47244 7.752358,0.61137 10.76562,2.42773 -2.433965,1.64193 -6.471862,1.23181 -7.285151,4.85743 -9.479196,22.45139 -12.471269,46.96783 -15.552735,70.95117 -3.621752,29.34547 -3.810268,59.0771 -1.185546,88.50976 5.062186,45.87599 20.801047,91.1762 49.363282,127.80469 19.95688,26.39173 47.3522,48.60821 80.10937,56.24414 22.47395,5.53265 45.44736,9.51266 68.57813,10.69727 8.5266,-0.40063 17.18762,-0.50715 25.58008,-2.02539 0.38196,-2.81994 -2.61781,-5.57722 -3.4961,-8.2461 -1.44653,-3.42348 -4.19801,-6.88649 -4.1582,-10.63867 2.13967,-1.54285 1.84049,-4.80023 4.6582,-5.89648 5.55455,-3.57415 12.83988,-2.42032 18.94141,-1.75782 5.75306,0.52405 7.14734,7.19265 9.875,11.22852 2.12351,3.09028 2.41953,7.87377 5.55859,10.0625 26.17162,-6.52384 52.36044,-14.89116 75.51172,-29.04492 17.05316,-10.54934 31.72418,-25.28626 40.90821,-43.20117 -0.321,-7.86722 0.0972,-15.83004 0.0234,-23.74805 -36.4662,9.9142 -97.74588,26.5181 -128.4375,31.87695 -44.54772,7.77818 -96.87505,9.19053 -150.61515,-30.40744 -65.69966,-48.41028 -44.54795,-229.80948 -35.35555,-265.16482 4.79814,-18.45438 17.9071,-38.64402 42.39062,-59.56055 z"
transform="scale(0.93749999)"
id="path4834"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccssccccccccccccccccccccccsssc" /><path
style="opacity:1;fill:#1e1e1e;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 221.25,457.42331 c 0,0 15,15.23437 22.03125,18.04687 7.03125,2.8125 20.85937,11.01563 28.125,14.76563 7.26562,3.75 7.73437,10.07812 22.96875,4.21875 15.23437,-5.85938 20.39062,-10.07813 30.9375,-11.71875 10.54687,-1.64063 18.04687,-0.9375 25.07812,-3.98438 7.03125,-3.04687 23.90625,-10.78125 28.59375,-13.35937 4.6875,-2.57813 9.60938,-2.57813 13.82813,-3.98438 4.21875,-1.40625 17.10937,-0.23437 19.45312,-3.75 2.34375,-3.51562 3.75,-3.98437 3.04688,-6.5625 -0.70313,-2.57812 -7.03125,-15 -15,-16.875 -7.96875,-1.875 -14.76563,-2.34375 -36.5625,-1.875 -21.79688,0.46875 -18.75,-1.17187 -39.14063,4.45313 -20.39062,5.625 -14.76562,10.78125 -37.26562,12.89062 -22.5,2.10938 -17.10938,2.10938 -29.53125,1.64063 -12.42188,-0.46875 -11.71875,-0.23438 -19.6875,-1.64063 -7.96875,-1.40625 -16.875,7.73438 -16.875,7.73438 z"
id="path4842"
inkscape:connector-curvature="0" /><path
style="opacity:1;fill:#1e1e1e;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 364.92187,465.15768 c 15,5.85938 16.875,39.60938 16.875,39.60938 0,0 3.98438,3.28125 4.21875,-0.70313 5.3692,-7.3069 -8.34035,-45.03528 -13.82812,-41.48437 z"
id="path4844"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" /><path
style="display:inline;opacity:1;fill:#1e1e1e;fill-opacity:1;stroke:none;stroke-width:0.883883;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;enable-background:new"
d="m 280.10156,140.0293 c -3.31241,1.49194 -6.45666,3.36696 -9.3457,5.52929 -5.80006,4.43544 -10.87913,10.55536 -12.1543,17.92774 -0.29285,2.25732 -0.37829,4.57376 -0.32617,6.82812 0.54553,0.71731 1.76636,0.2809 2.5957,0.47852 3.60567,0.16435 7.21143,0.36527 10.8125,0.60937 -3.09385,-7.05751 -2.40538,-18.24537 8.41797,-31.37304 z"
transform="scale(0.93749999)"
id="path4846"
inkscape:connector-curvature="0" /></g><g
id="layer2"
inkscape:label="gopher-face"
style="display:inline"
transform="translate(-159.49345,-229.70978)"><g
transform="matrix(-0.70545801,0,0,0.70545801,653.3373,199.47893)"
id="g4639"><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 547.42756,318.16456 c -0.44046,14.77191 -4.12869,29.02667 -10.38967,42.25266 -6.26099,13.22599 -15.09198,25.42687 -25.80466,35.99686 -10.71268,10.57 -23.30432,19.50822 -37.11826,26.08983 -13.81394,6.58161 -28.85103,10.80263 -44.50193,11.8618 -15.65091,1.05917 -30.4406,-1.15844 -43.81781,-6.16756 -13.37721,-5.00911 -25.3405,-12.8075 -35.30087,-22.80416 -9.96037,-9.99666 -17.91599,-22.19037 -23.26581,-35.90798 -5.34983,-13.71761 -8.0915,-28.95913 -7.64195,-44.98105 0.44955,-16.02192 4.04447,-31.2937 10.1422,-45.07896 6.09773,-13.78526 14.69591,-26.08175 25.16951,-36.25747 10.4736,-10.17571 22.82245,-18.23043 36.46168,-23.66123 13.63924,-5.4308 28.57214,-8.24285 44.22923,-8.02541 15.6571,0.21745 30.56095,3.42714 44.11009,8.94154 13.54914,5.5144 25.7404,13.33722 35.92568,22.91495 10.18529,9.57774 18.36233,20.91345 23.87736,33.53282 5.51504,12.61936 8.36566,26.52144 7.92521,41.29336 z"
id="path4631"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" /><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 539.72249,314.79002 c 10e-4,13.89984 -3.01572,27.53808 -8.51346,40.35257 -5.49774,12.81449 -13.48047,24.80543 -23.37659,35.2527 -9.89612,10.44726 -21.70519,19.34133 -34.78531,25.87862 -13.08011,6.53727 -27.4256,10.71236 -42.3773,11.7667 -14.9517,1.05435 -29.09103,-1.11258 -41.85904,-5.93108 -12.76803,-4.81852 -24.16883,-12.28715 -33.66552,-21.79076 -9.49671,-9.50362 -17.08979,-21.04298 -22.23241,-33.95465 -5.14261,-12.91166 -7.83328,-27.19561 -7.52333,-42.13595 0.30995,-14.94034 3.58995,-29.10832 9.22975,-41.85842 5.63981,-12.7501 13.63743,-24.08168 23.39638,-33.47108 9.75897,-9.38941 21.27795,-16.83842 34.00359,-21.94183 12.72563,-5.10342 26.66067,-7.86812 41.28534,-7.94317 14.62467,-0.0751 28.55938,2.53224 41.26083,7.24431 12.70145,4.71207 24.16709,11.5339 33.81555,20.03646 9.64847,8.50257 17.47884,18.68937 22.90117,30.21241 5.42232,11.52304 8.43889,24.38332 8.44035,38.28317 z"
id="path4633"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" /><circle
r="30.809652"
cy="314.25491"
cx="420.48679"
id="circle4635"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394455;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><circle
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="circle4637"
cx="406.84973"
cy="300.11276"
r="15.152287" /></g><g
id="g4651"
transform="matrix(-0.3718816,-0.33035833,-0.33035833,0.3718816,807.54151,424.47556)"><path
sodipodi:nodetypes="sssssssssssssssss"
inkscape:connector-curvature="0"
id="path4643"
d="m 549.05152,326.95134 c -3.75547,13.70752 -9.94485,27.49753 -18.134,40.75742 -8.18917,13.2599 -18.55963,25.88871 -30.48613,36.73483 -11.9265,10.84612 -25.26726,19.91416 -38.40631,26.7046 -13.13906,6.79044 -26.00962,11.35394 -38.37684,13.00513 -12.36722,1.6512 -23.25032,0.18545 -33.06847,-4.02278 -9.81817,-4.20823 -22.90634,-9.21469 -29.99096,-18.16657 -7.08461,-8.95188 -12.84054,-20.18257 -16.5035,-33.03389 -3.66297,-12.85133 -5.229,-27.32914 -3.92417,-42.72858 1.30484,-15.39944 5.36688,-30.24976 11.81788,-43.75488 6.45101,-13.5051 15.29008,-25.65823 26.00811,-35.78271 10.71803,-10.12447 28.44246,-20.29305 42.24879,-25.86698 13.80633,-5.57394 28.83304,-8.62768 44.20973,-8.80364 15.3767,-0.17594 29.62737,2.52591 41.94358,7.37479 12.31622,4.84887 22.69735,11.85058 30.35956,20.34718 7.66222,8.49661 12.72593,18.56023 14.82358,29.82149 2.09769,11.26125 1.23461,23.70707 -2.52085,37.41459 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><path
sodipodi:nodetypes="sssssssssssssssss"
inkscape:connector-curvature="0"
id="path4645"
d="m 543.48686,325.96521 c -3.05527,12.95406 -8.32379,26.18736 -15.65713,38.96025 -7.33335,12.77289 -16.92422,24.96643 -28.32938,35.27691 -11.40514,10.31051 -24.49416,18.73032 -37.45951,24.88997 -12.96533,6.15964 -25.70084,10.13776 -37.71064,11.47666 -12.00982,1.33889 -22.34344,-0.13174 -31.49437,-4.07588 -9.15093,-3.94412 -22.12504,-8.81495 -28.38237,-17.0256 -6.25732,-8.21068 -11.24381,-18.53447 -14.30417,-30.37519 -3.06035,-11.84072 -4.18965,-25.20221 -2.68634,-39.42576 1.5033,-14.22354 5.50837,-27.94818 11.67956,-40.43838 6.17119,-12.4902 14.50792,-23.74111 24.54768,-33.13895 10.03978,-9.39782 26.99021,-19.0621 39.83566,-24.2929 12.84546,-5.2308 26.78412,-8.15811 41.0009,-8.45853 14.21678,-0.30038 27.34319,2.03758 38.64284,6.33106 11.29965,4.29349 20.72984,10.51935 27.81498,18.21491 7.08513,7.69557 11.8105,16.84485 13.92296,27.20306 2.11247,10.35821 1.63459,21.92432 -1.42067,34.87837 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><circle
transform="rotate(9.4590451)"
r="27.721321"
cy="221.94495"
cx="451.74091"
id="circle4647"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394455;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><circle
transform="rotate(9.4590451)"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="circle4649"
cx="444.28159"
cy="208.59291"
r="13.633434" /></g></g><g
id="layer8"
inkscape:label="gopher-eye-shadow"
style="opacity:0.07"
transform="translate(-22.468606,-107.81499)"><path
style="opacity:1;fill:#1e1e1e;fill-opacity:1;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 183.98633,257.10938 c -12.96514,6.80368 -24.09238,16.98928 -31.2461,29.85742 -6.62306,11.80983 -9.18947,25.58787 -7.96289,39.02734 1.67308,22.69639 14.45708,43.56009 31.54688,58.10156 14.06811,11.68944 31.68858,19.74781 50.07226,20.84571 11.72752,0.49232 23.74714,-0.51041 34.5586,-5.42579 7.00377,-3.01395 13.43915,-7.17995 19.12109,-12.20898 -12.36895,1.55403 -17.4885,4.99626 -41.57617,5.11133 -34.01431,0.16249 -53,-11.5 -68.5,-36.5 -14.1749,-22.86275 -23.28438,-60.80338 13.98633,-98.80859 z"
transform="scale(0.93749999)"
id="path4853"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccssc" /><path
style="opacity:1;fill:#1e1e1e;fill-opacity:1;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 407.00195,240.87891 c -9.28173,2.40304 -16.98081,9.77947 -21.58398,18.09375 -9.07687,16.02983 -11.91205,35.42287 -8.52539,53.46875 3.18065,14.4141 9.49376,28.957 20.97265,38.67382 5.72719,4.40424 12.92201,6.42783 19.83399,8.13477 6.81141,1.14046 13.43258,-1.454 19.35351,-4.83008 -10.63686,0.15673 -18.09098,-0.62412 -29.80273,-7.00195 -16.87155,-9.18769 -20.86545,-23.47661 -24.25,-43.5 -2.91982,-17.27395 -3.79826,-39.70251 24.00195,-63.03906 z"
transform="scale(0.93749999)"
id="path4855"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccssc" /></g><g
id="layer7"
inkscape:label="gopher-mouth"
style="display:inline"
transform="translate(-159.49345,-229.70978)"><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#1e1e1e;fill-opacity:0.0854271;fill-rule:nonzero;stroke:none;stroke-width:11.1413;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 479.16003,446.53263 c -0.5609,0.0108 -1.13139,0.0418 -1.70837,0.0934 -2.30796,0.20633 -4.51646,0.72546 -6.51307,1.48133 -1.9966,0.75587 -3.78067,1.74769 -5.22583,2.89856 -1.0355,0.82461 -1.88701,1.73417 -2.51953,2.69165 -0.49936,0.12274 -1.0048,0.23296 -1.49781,0.36987 -3.81399,1.05919 -7.40798,2.54289 -10.40404,4.35608 -2.99605,1.81321 -5.4585,3.90209 -6.89576,6.26037 -1.4722,2.41563 -1.97188,5.13866 -1.80359,7.29493 0.19753,2.5307 1.75758,4.81955 3.97339,6.88111 2.21579,2.06154 5.27574,3.88827 8.8202,5.38513 1.41204,0.59632 2.90966,1.1203 4.4403,1.59484 l -0.83679,5.84107 -1.56738,7.92297 c -0.28234,1.42686 0.0936,2.6844 0.78552,3.59985 0.69199,0.91542 1.75347,1.43692 3.00476,1.46668 l 4.17114,0.10071 8.25256,-0.19226 c 1.58268,-0.0369 3.05271,-0.65206 4.13086,-1.59668 1.07816,-0.94464 1.71703,-2.22901 1.84571,-3.63098 l 0.72143,-7.86255 0.29663,-3.98255 c 3.44589,-0.52696 6.56272,-1.40104 9.31274,-2.44995 3.13519,-1.19582 5.76942,-2.61386 7.84425,-4.18763 2.07486,-1.57375 3.60911,-3.3186 4.57763,-5.26427 0.96853,-1.94567 1.36942,-4.09866 1.01807,-6.41968 -0.35134,-2.32102 -1.43875,-4.58369 -3.11829,-6.66321 -1.39778,-1.73064 -3.21452,-3.31999 -5.30822,-4.7168 0.0111,-0.55256 -0.0197,-1.11781 -0.13001,-1.69922 -0.26781,-1.4117 -0.89157,-2.76339 -1.83655,-3.9862 -0.94497,-1.22282 -2.2106,-2.31507 -3.73901,-3.20068 -1.52841,-0.88563 -3.32124,-1.56509 -5.31189,-1.96839 -1.49298,-0.30248 -3.09635,-0.44985 -4.77905,-0.41748 z"
id="path4863"
inkscape:connector-curvature="0" /><g
id="g4823"
transform="translate(12.595339,-3.9774756)"><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#2e3436;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:11.1413;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 467.31675,478.77402 -7.09437,0.0922 -4.13468,-0.0915 c -1.31882,-0.0292 -2.49364,0.59671 -3.34977,1.51944 -0.85615,0.92274 -1.27744,2.20064 -1.47679,3.59515 l -1.12865,7.8943 -1.5677,7.92258 c -0.28234,1.42686 0.0937,2.68467 0.78566,3.60012 0.69199,0.91542 1.75323,1.43698 3.00452,1.46674 l 4.17197,0.0993 8.25189,-0.19228 c 1.58268,-0.0369 3.05287,-0.65192 4.13103,-1.59654 1.07815,-0.94464 1.7158,-2.22919 1.84447,-3.63117 l 0.72151,-7.86154 0.58517,-7.8491 c 0.10417,-1.3971 -0.52004,-2.64858 -1.39674,-3.54475 -0.8767,-0.89618 -2.06937,-1.43077 -3.34749,-1.42293 z"
id="path4686"
inkscape:connector-curvature="0"
sodipodi:nodetypes="scssscssscssscsss" /><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:11.1413;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 466.02149,481.16374 -5.78995,0.0778 -2.75744,-0.11307 c -1.04748,-0.043 -2.01463,0.4856 -2.74214,1.24096 -0.72751,0.75535 -1.15228,1.80234 -1.35005,2.94507 l -1.11703,6.45444 -1.39816,6.47122 c -0.24964,1.15537 0.002,2.19546 0.54523,2.94311 0.5435,0.74764 1.41108,1.15214 2.44988,1.1934 l 2.83204,0.11253 6.55718,-0.1421 c 1.24167,-0.027 2.42344,-0.526 3.31255,-1.29718 0.88911,-0.77118 1.45844,-1.82149 1.60367,-2.96928 l 0.81341,-6.4286 0.70226,-6.42074 c 0.12544,-1.14351 -0.31817,-2.1697 -0.99356,-2.90365 -0.67536,-0.73395 -1.62282,-1.178 -2.66787,-1.16394 z"
id="path4688"
inkscape:connector-curvature="0"
sodipodi:nodetypes="scssscssscssscsss" /><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:11.1413;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 433.73526,472.08025 c 0.19752,2.5307 1.75649,4.82026 3.9723,6.88181 2.21579,2.06155 5.27599,3.88893 8.82045,5.38579 3.54446,1.49687 7.54259,2.64799 11.59243,3.26302 4.04985,0.61505 8.15288,0.68338 12.18571,0.19715 4.03281,-0.48626 7.64449,-1.45413 10.77968,-2.64995 3.13518,-1.19582 5.76946,-2.61453 7.84429,-4.18829 2.07486,-1.57376 3.60891,-3.31701 4.57744,-5.26269 0.96853,-1.94567 1.37034,-4.09954 1.01899,-6.42056 -0.35134,-2.32102 -1.4393,-4.58444 -3.11884,-6.66396 -1.67955,-2.07951 -3.93835,-3.97272 -6.59166,-5.54818 -2.6533,-1.57546 -5.69659,-2.83225 -9.05614,-3.65611 -3.35955,-0.82385 -7.03889,-1.21547 -11.06436,-1.08264 -4.02547,0.13285 -8.04246,0.77372 -11.85647,1.83293 -3.814,1.05919 -7.40852,2.54378 -10.40458,4.35697 -2.99604,1.81321 -5.45804,3.90231 -6.89529,6.26059 -1.4722,2.41563 -1.97224,5.13785 -1.80395,7.29412 z"
id="path4690"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" /><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#e1d0cb;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:11.1413;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 442.25761,471.51199 c 0.34559,2.01022 1.52164,3.88762 3.32749,5.56973 1.80585,1.68211 4.23932,3.17214 7.00457,4.36112 2.76524,1.18899 5.84856,2.06805 8.99234,2.52986 3.14378,0.46181 6.35142,0.50432 9.50541,0.11936 3.15398,-0.38496 5.97075,-1.14729 8.41503,-2.12187 2.44428,-0.97459 4.50648,-2.15972 6.12437,-3.48208 1.61791,-1.32236 2.79548,-2.78732 3.48967,-4.38292 0.69419,-1.59559 0.90742,-3.32616 0.54576,-5.15905 -0.36166,-1.8329 -1.26917,-3.59361 -2.60863,-5.20009 -1.33945,-1.60649 -3.10784,-3.05657 -5.19076,-4.25685 -2.08293,-1.20029 -4.4781,-2.15007 -7.11901,-2.76427 -2.6409,-0.61421 -5.52795,-0.89311 -8.65369,-0.76365 -3.12573,0.12948 -6.20924,0.65323 -9.12183,1.4947 -2.91261,0.84148 -5.65005,2.00273 -7.96189,3.4111 -2.31186,1.40839 -4.18867,3.06443 -5.38415,4.87182 -1.19548,1.8074 -1.71029,3.76288 -1.36468,5.77309 z"
id="path4692"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" /><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:11.1413;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 453.44155,459.24387 c 0.50394,1.4405 1.59973,2.71976 3.11582,3.79941 1.51609,1.07966 3.44645,1.96175 5.54093,2.63816 2.09447,0.67641 4.34429,1.14922 6.54588,1.40092 2.20159,0.25168 4.3549,0.28101 6.37262,0.0579 2.01772,-0.22314 3.7207,-0.67675 5.162,-1.30838 1.44132,-0.63166 2.6208,-1.44105 3.55014,-2.39383 0.92933,-0.95279 1.60959,-2.05056 2.00449,-3.26657 0.39491,-1.21603 0.50374,-2.55168 0.23592,-3.96339 -0.26781,-1.41171 -0.89241,-2.76478 -1.83739,-3.98759 -0.94498,-1.22282 -2.20943,-2.31451 -3.73784,-3.20012 -1.52841,-0.88562 -3.32073,-1.5647 -5.31138,-1.968 -1.99064,-0.40331 -4.17987,-0.53049 -6.48783,-0.32418 -2.30795,0.20632 -4.51621,0.7257 -6.51282,1.48157 -1.9966,0.75587 -3.78172,1.74809 -5.22688,2.89895 -1.44517,1.15085 -2.55022,2.46103 -3.16907,3.84701 -0.61885,1.38597 -0.74853,2.84764 -0.24459,4.28815 z"
id="path4694"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" /></g></g><g
id="layer3"
inkscape:label="gopher-eye-lashes"
style="display:inline"
transform="translate(-159.49345,-229.70978)" /><g
id="g2"
transform="matrix(0.29908034,-0.07349009,0.08149446,0.24234368,225.1279,272.80177)"
inkscape:label="folder"
style="display:inline"><path
id="SVGCleanerId_0"
style="fill:#ffc36e"
d="M 183.295,123.586 H 55.05 c -6.687,0 -12.801,-3.778 -15.791,-9.76 L 26.483,88.276 39.259,62.726 c 2.99,-5.982 9.103,-9.76 15.791,-9.76 h 128.246 c 6.687,0 12.801,3.778 15.791,9.76 l 12.775,25.55 -12.776,25.55 c -2.99,5.982 -9.103,9.76 -15.791,9.76 z" /><g
id="g1-9">
<path
id="SVGCleanerId_0_1_"
style="fill:#ffc36e"
d="M 183.295,123.586 H 55.05 c -6.687,0 -12.801,-3.778 -15.791,-9.76 L 26.483,88.276 39.259,62.726 c 2.99,-5.982 9.103,-9.76 15.791,-9.76 h 128.246 c 6.687,0 12.801,3.778 15.791,9.76 l 12.775,25.55 -12.776,25.55 c -2.99,5.982 -9.103,9.76 -15.791,9.76 z" />
</g><path
style="fill:#eff2fa"
d="M 485.517,70.621 H 26.483 c -4.875,0 -8.828,3.953 -8.828,8.828 v 44.138 h 476.69 V 79.448 c 0,-4.875 -3.953,-8.827 -8.828,-8.827 z"
id="path1" /><rect
x="17.655001"
y="105.931"
style="fill:#e1e6f2"
width="476.69"
height="17.655001"
id="rect1" /><path
style="fill:#ffd782"
d="M 494.345,88.276 H 217.318 c -3.343,0 -6.4,1.889 -7.895,4.879 l -10.336,20.671 c -2.99,5.982 -9.105,9.76 -15.791,9.76 H 55.05 c -6.687,0 -12.801,-3.778 -15.791,-9.76 L 28.922,93.155 C 27.427,90.165 24.37,88.276 21.027,88.276 H 17.655 C 7.904,88.276 0,96.18 0,105.931 v 335.448 c 0,9.751 7.904,17.655 17.655,17.655 h 476.69 c 9.751,0 17.655,-7.904 17.655,-17.655 V 105.931 c 0,-9.751 -7.904,-17.655 -17.655,-17.655 z"
id="path2" /><path
style="fill:#ffc36e"
d="M 485.517,441.379 H 26.483 c -4.875,0 -8.828,-3.953 -8.828,-8.828 v 0 c 0,-4.875 3.953,-8.828 8.828,-8.828 h 459.034 c 4.875,0 8.828,3.953 8.828,8.828 v 0 c 0,4.876 -3.953,8.828 -8.828,8.828 z"
id="path3" /><path
style="fill:#eff2fa"
d="m 326.621,220.69 h 132.414 c 4.875,0 8.828,-3.953 8.828,-8.828 v -70.621 c 0,-4.875 -3.953,-8.828 -8.828,-8.828 H 326.621 c -4.875,0 -8.828,3.953 -8.828,8.828 v 70.621 c 0,4.875 3.953,8.828 8.828,8.828 z"
id="path4" /><path
style="fill:#c7cfe2"
d="m 441.379,167.724 h -97.103 c -4.875,0 -8.828,-3.953 -8.828,-8.828 v 0 c 0,-4.875 3.953,-8.828 8.828,-8.828 h 97.103 c 4.875,0 8.828,3.953 8.828,8.828 v 0 c 0,4.876 -3.953,8.828 -8.828,8.828 z"
id="path5" /><path
style="fill:#d7deed"
d="m 441.379,203.034 h -97.103 c -4.875,0 -8.828,-3.953 -8.828,-8.828 v 0 c 0,-4.875 3.953,-8.828 8.828,-8.828 h 97.103 c 4.875,0 8.828,3.953 8.828,8.828 v 0 c 0,4.876 -3.953,8.828 -8.828,8.828 z"
id="path6" /></g><g
id="layer12"
inkscape:label="gopher-hands"
style="display:inline"
transform="translate(-159.49345,-229.70978)"><g
transform="matrix(-0.73022337,-0.0504068,0.04754695,1.0110636,636.13477,-6.0140033)"
id="g4702"><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#e1d6b9;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 423.50332,581.83521 c -0.004,4.40048 -1.19837,7.58856 -3.37524,9.82844 -2.17687,2.23987 -5.33154,3.55156 -9.14619,4.44292 -3.81465,0.89135 -8.28246,1.39523 -13.05675,1.83828 -4.77428,0.44304 -9.85163,0.79076 -14.95001,1.09928 -5.09838,0.30851 -9.94541,0.34741 -14.40217,0.0862 -4.45676,-0.26122 -8.52354,-0.79908 -11.99271,-1.71189 -3.46915,-0.91282 -6.33736,-2.21356 -8.3562,-4.09288 -2.01885,-1.87935 -3.18709,-4.34475 -3.25466,-7.51083 -0.0676,-3.16607 0.9983,-5.4859 2.92534,-7.0838 1.92703,-1.5979 4.71248,-2.46394 8.09977,-2.84688 3.38729,-0.38293 7.37282,-0.28336 11.77044,-0.16051 4.39762,0.12284 9.21051,0.23456 14.33166,-0.12202 5.12115,-0.35659 10.27171,-1.47349 15.16022,-2.54099 4.88852,-1.06749 9.50395,-2.05149 13.43823,-2.27114 3.9343,-0.21967 7.17754,0.32322 9.39823,2.04598 2.22069,1.72276 3.41425,4.59936 3.41004,8.99986 z"
id="path4698"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" /><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 411.91406,568.54883 c -3.75011,-0.0271 -8.08701,0.53975 -12.76172,1.28711 -5.34251,0.85413 -11.10706,1.92059 -17.00976,2.32617 -5.9027,0.40562 -11.41103,0.38326 -16.44727,0.41406 -5.03624,0.0309 -9.6045,0.1607 -13.50781,0.85938 -3.9033,0.69867 -7.13503,1.96743 -9.4082,3.96875 -2.27316,2.00131 -3.58535,4.71676 -3.65235,8.17578 -0.067,3.45901 1.21821,6.3073 3.54297,8.58008 2.32476,2.27278 5.68789,3.9795 9.76172,5.25 4.07385,1.27051 8.85237,2.11894 14.05664,2.59765 5.20427,0.47871 10.83381,0.56134 16.70313,0.22266 5.86931,-0.33868 11.47146,-0.78653 16.60547,-1.34961 5.13399,-0.56309 9.79334,-1.22365 13.70703,-2.34375 1.48913,-0.4262 2.86677,-0.9287 4.12695,-1.51953 2.54507,-1.19325 2.05015,-6.17249 -0.0996,-4.54102 -1.99172,1.51153 -4.55969,2.50355 -7.57031,3.20703 -3.66893,0.85731 -7.96668,1.34146 -12.5586,1.76758 -4.59191,0.42612 -9.47527,0.75991 -14.3789,1.05664 -4.90363,0.29673 -9.56506,0.33523 -13.85156,0.084 -4.28652,-0.25124 -8.19851,-0.76855 -11.53516,-1.64649 -3.33664,-0.87795 -6.09539,-2.12996 -8.03711,-3.9375 -1.94173,-1.80756 -3.06587,-4.17751 -3.13086,-7.22265 -0.065,-3.04513 0.96102,-5.2776 2.81445,-6.81446 1.85342,-1.53686 4.53117,-2.36997 7.78907,-2.73828 3.2579,-0.36831 7.09262,-0.27244 11.32226,-0.1543 4.22963,0.11816 8.85767,0.22578 13.7832,-0.11718 4.92553,-0.34297 9.88026,-1.41664 14.58204,-2.44336 4.70178,-1.02671 9.13982,-1.97234 12.92382,-2.1836 0.473,-0.0264 0.93707,-0.0422 1.38868,-0.0449 1.16046,-0.007 2.25007,0.0442 3.25,0.23633 1.15313,0.22156 2.31543,-2.86146 -0.83789,-2.92773 -0.51177,-0.0108 -1.03459,-0.045 -1.57032,-0.0488 z"
id="path4700"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ssscsscssssssssssssssssssssccsss" /></g></g><g
id="layer4"
inkscape:label="palette"
style="display:none"
transform="translate(-159.49345,-229.70978)"><rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4162"
width="40.789474"
height="40.789474"
x="779.60529"
y="21.967466" /><rect
y="21.967466"
x="824.60529"
height="40.789474"
width="40.789474"
id="rect4170"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#bce8ff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4208"
width="40.789474"
height="40.789474"
x="779.60529"
y="86.967468" /><rect
y="-127.75694"
x="824.60529"
height="40.789474"
width="40.789474"
id="rect4223"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#abccd9;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
transform="scale(1,-1)" /><rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#c3b0cb;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4227"
width="40.789474"
height="40.789474"
x="779.60529"
y="131.96747" /><rect
y="131.96747"
x="824.60529"
height="40.789474"
width="40.789474"
id="rect4231"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#e1d0cb;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#f5c3d2;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4233"
width="40.789474"
height="40.789474"
x="869.60529"
y="131.96747" /><rect
y="176.96747"
x="779.60529"
height="40.789474"
width="40.789474"
id="rect4248"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#cec4ad;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><rect
transform="scale(1,-1)"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#96d6ff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4263"
width="40.789474"
height="40.789474"
x="869.60529"
y="-127.75694" /><rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#f2f2ce;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4267"
width="40.789474"
height="40.789474"
x="824.60529"
y="176.96747" /><rect
y="-327.75693"
x="779.60529"
height="40.789474"
width="40.789474"
id="rect4280"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#24b8eb;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
transform="scale(1,-1)" /><rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#8aa9ff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4284"
width="40.789474"
height="40.789474"
x="824.60529"
y="286.96747" /><rect
y="331.96747"
x="779.60529"
height="40.789474"
width="40.789474"
id="rect4297"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#d4edf1;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394d54;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4301"
width="40.789474"
height="40.789474"
x="779.60529"
y="241.96747" /><rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#d6e2ff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4303"
width="40.789474"
height="40.789474"
x="824.60529"
y="331.96747" /></g></g></g></svg>

After

Width:  |  Height:  |  Size: 72 KiB