[preview] Remove plugin (integrated into core now)
This commit is contained in:
@@ -1,2 +0,0 @@
|
||||
# simple redirect
|
||||
from .url_image_preview import UrlImagePreviewPlugin
|
||||
@@ -1,96 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2017 Philipp Hörist <philipp AT hoerist.com>
|
||||
#
|
||||
# This file is part of Gajim.
|
||||
#
|
||||
# Gajim is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Gajim is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
|
||||
from gajim.gui.settings import SettingsDialog
|
||||
from gajim.gui.settings import SpinSetting
|
||||
from gajim.gui.const import Setting
|
||||
from gajim.gui.const import SettingKind
|
||||
from gajim.gui.const import SettingType
|
||||
|
||||
from gajim.plugins.plugins_i18n import _
|
||||
|
||||
|
||||
class UrlImagePreviewConfigDialog(SettingsDialog):
|
||||
def __init__(self, plugin, parent):
|
||||
|
||||
sizes = [('262144', '256 KiB'),
|
||||
('524288', '512 KiB'),
|
||||
('1048576', '1 MiB'),
|
||||
('5242880', '5 MiB'),
|
||||
('10485760', '10 MiB')]
|
||||
|
||||
actions = [
|
||||
('open', _('Open')),
|
||||
('save_as', _('Save as')),
|
||||
('open_folder', _('Open Folder')),
|
||||
('copy_link_location', _('Copy Link Location')),
|
||||
('open_link_in_browser', _('Open Link in Browser'))]
|
||||
|
||||
self.plugin = plugin
|
||||
settings = [
|
||||
Setting('PreviewSizeSpinSetting', _('Preview Size'),
|
||||
SettingType.VALUE, self.plugin.config['PREVIEW_SIZE'],
|
||||
callback=self.on_setting, data='PREVIEW_SIZE',
|
||||
desc=_('Size of preview image'),
|
||||
props={'range_': (100, 1000)}),
|
||||
|
||||
Setting(SettingKind.COMBO, _('File Size'),
|
||||
SettingType.VALUE, self.plugin.config['MAX_FILE_SIZE'],
|
||||
callback=self.on_setting, data='MAX_FILE_SIZE',
|
||||
desc=_('Maximum file size for preview generation'),
|
||||
props={'combo_items': sizes}),
|
||||
|
||||
Setting(SettingKind.SWITCH, _('Preview all Image URLs'),
|
||||
SettingType.VALUE, self.plugin.config['ALLOW_ALL_IMAGES'],
|
||||
callback=self.on_setting, data='ALLOW_ALL_IMAGES',
|
||||
desc=_('Generate preview for any URL containing images '
|
||||
'(may be unsafe)')),
|
||||
|
||||
Setting(SettingKind.COMBO, _('Left Click'),
|
||||
SettingType.VALUE, self.plugin.config['LEFTCLICK_ACTION'],
|
||||
callback=self.on_setting, data='LEFTCLICK_ACTION',
|
||||
desc=_('Action when left clicking a preview'),
|
||||
props={'combo_items': actions}),
|
||||
|
||||
Setting(SettingKind.SWITCH, _('HTTPS Verification'),
|
||||
SettingType.VALUE, self.plugin.config['VERIFY'],
|
||||
desc=_('Whether to check for a valid certificate'),
|
||||
callback=self.on_setting, data='VERIFY'),
|
||||
]
|
||||
|
||||
SettingsDialog.__init__(self, parent, _('UrlImagePreview Configuration'),
|
||||
Gtk.DialogFlags.MODAL, settings, None,
|
||||
extend=[('PreviewSizeSpinSetting',
|
||||
SizeSpinSetting)])
|
||||
|
||||
def on_setting(self, value, data):
|
||||
self.plugin.config[data] = value
|
||||
|
||||
|
||||
class SizeSpinSetting(SpinSetting):
|
||||
|
||||
__gproperties__ = {
|
||||
"setting-value": (int, 'Size', '', 100, 1000, 300,
|
||||
GObject.ParamFlags.READWRITE), }
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
SpinSetting.__init__(self, *args, **kwargs)
|
||||
@@ -1,62 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.22.2 -->
|
||||
<interface>
|
||||
<requires lib="gtk+" version="3.20"/>
|
||||
<object class="GtkMenu" id="context_menu">
|
||||
<property name="can_focus">False</property>
|
||||
<child>
|
||||
<object class="GtkMenuItem" id="download">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes">_Download</property>
|
||||
<property name="use_underline">True</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkMenuItem" id="open">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes">_Open</property>
|
||||
<property name="use_underline">True</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkMenuItem" id="save_as">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes">_Save as</property>
|
||||
<property name="use_underline">True</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkMenuItem" id="open_folder">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes">Open _Folder</property>
|
||||
<property name="use_underline">True</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkSeparatorMenuItem" id="encryption_separator">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkMenuItem" id="copy_link_location">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes">_Copy Link</property>
|
||||
<property name="use_underline">True</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkMenuItem" id="open_link_in_browser">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes">Open Link in _Browser</property>
|
||||
<property name="use_underline">True</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@@ -1,13 +0,0 @@
|
||||
[info]
|
||||
name: Url image preview
|
||||
short_name: url_image_preview
|
||||
version: 2.6.8
|
||||
description: Displays a preview of image links.
|
||||
authors = Denis Fomin <fominde@gmail.com>
|
||||
Yann Leboulanger <asterix@lagaule.org>
|
||||
Anders Sandblad <runeson@gmail.com>
|
||||
Thilo Molitor <thilo@eightysoft.de>
|
||||
Philipp Hoerist <philipp@hoerist.com>
|
||||
homepage = https://dev.gajim.org/gajim/gajim-plugins/wikis/UrlImagePreviewPlugin
|
||||
min_gajim_version: 1.4.0-dev1
|
||||
max_gajim_version: 1.4.90
|
||||
@@ -1,107 +0,0 @@
|
||||
# This is an excerpt of Media Types from
|
||||
# https://www.iana.org/assignments/media-types/media-types.xhtml
|
||||
# plus some additions
|
||||
MIME_TYPES = (
|
||||
# application/
|
||||
'application/calendar+json',
|
||||
'application/calendar+xml',
|
||||
'application/epub+zip',
|
||||
'application/json',
|
||||
'application/mp4',
|
||||
'application/msword',
|
||||
'application/octet-stream',
|
||||
'application/ogg',
|
||||
'application/pdf',
|
||||
'application/pgp-encrypted',
|
||||
'application/pgp-signature',
|
||||
'application/postscript',
|
||||
'application/rtf',
|
||||
'application/vcard+json',
|
||||
'application/vcard+xml',
|
||||
'application/vnd.amazon.mobi8-ebook',
|
||||
'application/vnd.google-earth.kml+xml',
|
||||
'application/vnd.google-earth.kmz',
|
||||
# Start office
|
||||
'application/vnd.ms-access',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.ms-excel.addin.macroEnabled.12',
|
||||
'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
|
||||
'application/vnd.ms-excel.sheet.macroEnabled.12',
|
||||
'application/vnd.ms-excel.template.macroEnabled.12',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.ms-powerpoint.addin.macroEnabled.12',
|
||||
'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
|
||||
'application/vnd.ms-powerpoint.slideshow.macroEnabled.12',
|
||||
'application/vnd.ms-powerpoint.template.macroEnabled.12',
|
||||
'application/vnd.ms-word.document.macroEnabled.12',
|
||||
'application/vnd.ms-word.template.macroEnabled.12',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.template',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
|
||||
'application/vnd.openxmlformats-officedocument.vmlDrawing',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
|
||||
# End office
|
||||
'application/vnd.sqlite3',
|
||||
'application/zip',
|
||||
# audio/*
|
||||
'audio/aac',
|
||||
'audio/ac3',
|
||||
'audio/flac',
|
||||
'audio/mp4',
|
||||
'audio/mpeg',
|
||||
'audio/ogg',
|
||||
'audio/opus',
|
||||
'audio/wav',
|
||||
'audio/x-flac',
|
||||
'audio/x-m4a',
|
||||
'audio/x-matroska',
|
||||
# font/*
|
||||
'font/ttf',
|
||||
'font/woff',
|
||||
'font/woff2',
|
||||
# image/*
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
'image/jxl',
|
||||
'image/bmp',
|
||||
'image/x-bmp',
|
||||
'image/x-ms-bmp',
|
||||
'image/gif',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/svg+xml',
|
||||
'image/tiff',
|
||||
'image/vnd.adobe.photoshop',
|
||||
'image/vnd.dwg',
|
||||
'image/vnd.dxf',
|
||||
'image/vnd.microsoft.icon',
|
||||
'image/x-icon',
|
||||
'image/x-xcf',
|
||||
# model/*
|
||||
'model/mtl',
|
||||
'model/obj',
|
||||
'model/stl',
|
||||
# text/*
|
||||
'text/calendar',
|
||||
'text/csv',
|
||||
'text/markdown',
|
||||
'text/rtf',
|
||||
'text/vcard',
|
||||
# video/*
|
||||
'video/H264',
|
||||
'video/H265',
|
||||
'video/mp4',
|
||||
'video/mpeg4-generic',
|
||||
'video/ogg',
|
||||
'video/quicktime',
|
||||
'video/vc1',
|
||||
'video/VP8',
|
||||
'video/webm',
|
||||
'video/x-matroska',
|
||||
'video/x-msvideo',
|
||||
)
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="addon">
|
||||
<id>org.gajim.Gajim.Plugin.url_image_preview</id>
|
||||
<extends>org.gajim.Gajim</extends>
|
||||
<name>URL Image Preview Plugin</name>
|
||||
<summary>Display a preview of links to images</summary>
|
||||
<url type="homepage">https://gajim.org/</url>
|
||||
<metadata_license>CC-BY-SA-3.0</metadata_license>
|
||||
<project_license>GPL-3.0</project_license>
|
||||
<update_contact>gajim-devel_AT_gajim.org</update_contact>
|
||||
</component>
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
.preview-box {
|
||||
border: 1px solid;
|
||||
border-color: @borders;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
margin: 5px;
|
||||
background-color: @theme_unfocused_base_color;
|
||||
}
|
||||
.preview-image-button,
|
||||
.preview-image-button:hover {
|
||||
padding: 0px;
|
||||
border: none;
|
||||
transition: none;
|
||||
-gtk-icon-effect: none;
|
||||
-gtk-icon-shadow: none;
|
||||
background-image: none;
|
||||
text-shadow: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.preview-button {
|
||||
border: 1px solid @borders;
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.38.2 -->
|
||||
<interface>
|
||||
<requires lib="gtk+" version="3.20"/>
|
||||
<object class="GtkBox" id="preview_box">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">3</property>
|
||||
<child>
|
||||
<object class="GtkEventBox" id="image_event_box">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="halign">start</property>
|
||||
<signal name="realize" handler="_on_realize" swapped="no"/>
|
||||
<child>
|
||||
<object class="GtkButton" id="image_button">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">False</property>
|
||||
<property name="relief">none</property>
|
||||
<child>
|
||||
<placeholder/>
|
||||
</child>
|
||||
<style>
|
||||
<class name="preview-image-button"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="button_box">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkEventBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="valign">end</property>
|
||||
<signal name="realize" handler="_on_realize" swapped="no"/>
|
||||
<child>
|
||||
<object class="GtkButton" id="save_as_button">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">False</property>
|
||||
<property name="tooltip-text" translatable="yes">Save as...</property>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="icon-name">document-save-as-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
<style>
|
||||
<class name="preview-button"/>
|
||||
<class name="flat"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="pack-type">end</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkEventBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="valign">end</property>
|
||||
<signal name="realize" handler="_on_realize" swapped="no"/>
|
||||
<child>
|
||||
<object class="GtkButton" id="open_folder_button">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">False</property>
|
||||
<property name="tooltip-text" translatable="yes">Open folder</property>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="icon-name">folder-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
<style>
|
||||
<class name="preview-button"/>
|
||||
<class name="flat"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="pack-type">end</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="file_name">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="selectable">True</property>
|
||||
<property name="ellipsize">end</property>
|
||||
<property name="single-line-mode">True</property>
|
||||
<property name="xalign">0</property>
|
||||
<style>
|
||||
<class name="dim-label"/>
|
||||
</style>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="file_size">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="single-line-mode">True</property>
|
||||
<property name="xalign">0</property>
|
||||
<style>
|
||||
<class name="dim-label"/>
|
||||
</style>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">3</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkEventBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="valign">end</property>
|
||||
<signal name="realize" handler="_on_realize" swapped="no"/>
|
||||
<child>
|
||||
<object class="GtkButton" id="download_button">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">False</property>
|
||||
<property name="tooltip-text" translatable="yes">Download</property>
|
||||
<property name="relief">none</property>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="icon-name">folder-download-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
<style>
|
||||
<class name="preview-button"/>
|
||||
<class name="flat"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="pack-type">end</property>
|
||||
<property name="position">3</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="pack-type">end</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
<style>
|
||||
<class name="preview-box"/>
|
||||
</style>
|
||||
</object>
|
||||
</interface>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 684 B |
@@ -1,953 +0,0 @@
|
||||
# This file is part of Image Preview Gajim Plugin.
|
||||
#
|
||||
# Image Preview Gajim Plugin is free software;
|
||||
# you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation; version 3 only.
|
||||
#
|
||||
# Image Preview Gajim Plugin is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Image Preview Gajim Plugin.
|
||||
# If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import logging
|
||||
import shutil
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from functools import partial
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import gi
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Gdk
|
||||
from gi.repository import GdkPixbuf
|
||||
from gi.repository import Gio
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Soup
|
||||
try:
|
||||
gi.require_version('Gst', '1.0')
|
||||
gi.require_version('GstPbutils', '1.0')
|
||||
from gi.repository import Gst
|
||||
from gi.repository import GstPbutils
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
from gajim.common import app
|
||||
from gajim.common import configpaths
|
||||
from gajim.common.helpers import open_file
|
||||
from gajim.common.helpers import open_uri
|
||||
from gajim.common.helpers import write_file_async
|
||||
from gajim.common.helpers import load_file_async
|
||||
from gajim.common.helpers import get_tls_error_phrase
|
||||
from gajim.common.helpers import get_user_proxy
|
||||
from gajim.gui.dialogs import ErrorDialog
|
||||
from gajim.gui.filechoosers import FileSaveDialog
|
||||
from gajim.gui.util import get_cursor
|
||||
from gajim.gui.util import get_monitor_scale_factor
|
||||
from gajim.gui.util import load_icon
|
||||
|
||||
from gajim.plugins import GajimPlugin
|
||||
from gajim.plugins.helpers import get_builder
|
||||
from gajim.plugins.plugins_i18n import _
|
||||
|
||||
from url_image_preview.config_dialog import UrlImagePreviewConfigDialog
|
||||
from url_image_preview.mime_types import MIME_TYPES
|
||||
|
||||
log = logging.getLogger('gajim.p.preview')
|
||||
|
||||
ERROR_MSG = None
|
||||
try:
|
||||
from PIL import Image # pylint: disable=unused-import
|
||||
except ImportError:
|
||||
Image = None
|
||||
log.error('Pillow not available')
|
||||
ERROR_MSG = _('Please install python-pillow')
|
||||
|
||||
try:
|
||||
import cryptography # pylint: disable=unused-import
|
||||
except Exception:
|
||||
ERROR_MSG = _('Please install python-cryptography')
|
||||
log.error('python-cryptography not available')
|
||||
|
||||
# pylint: disable=ungrouped-imports
|
||||
if ERROR_MSG is None:
|
||||
from url_image_preview.utils import aes_decrypt
|
||||
from url_image_preview.utils import get_image_paths
|
||||
from url_image_preview.utils import split_geo_uri
|
||||
from url_image_preview.utils import parse_fragment
|
||||
from url_image_preview.utils import create_thumbnail
|
||||
from url_image_preview.utils import pixbuf_from_data
|
||||
from url_image_preview.utils import filename_from_uri
|
||||
# pylint: enable=ungrouped-imports
|
||||
|
||||
|
||||
def get_previewable_mime_types():
|
||||
previewable_mime_types = set()
|
||||
for fmt in GdkPixbuf.Pixbuf.get_formats():
|
||||
for mime_type in fmt.get_mime_types():
|
||||
previewable_mime_types.add(mime_type.lower())
|
||||
if Image is not None:
|
||||
Image.init()
|
||||
for mime_type in Image.MIME.values():
|
||||
previewable_mime_types.add(mime_type.lower())
|
||||
return tuple(filter(
|
||||
lambda mime_type: mime_type.startswith('image'),
|
||||
previewable_mime_types
|
||||
))
|
||||
|
||||
|
||||
PREVIEWABLE_MIME_TYPES = get_previewable_mime_types()
|
||||
mime_types = set(MIME_TYPES)
|
||||
# Merge both: if it’s a previewable image, it should be allowed
|
||||
ALLOWED_MIME_TYPES = mime_types.union(PREVIEWABLE_MIME_TYPES)
|
||||
|
||||
|
||||
class UrlImagePreviewPlugin(GajimPlugin):
|
||||
def init(self):
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
if ERROR_MSG:
|
||||
self.activatable = False
|
||||
self.available_text = ERROR_MSG
|
||||
self.config_dialog = None
|
||||
return
|
||||
|
||||
self.description = _('Displays a preview of image links.')
|
||||
self.config_dialog = partial(UrlImagePreviewConfigDialog, self)
|
||||
|
||||
self.gui_extension_points = {
|
||||
'chat_control_base': (self._on_connect_chat_control_base,
|
||||
self._on_disconnect_chat_control_base),
|
||||
'history_window': (self._on_connect_history_window,
|
||||
self._on_disconnect_history_window),
|
||||
'print_real_text': (self._print_real_text, None),
|
||||
}
|
||||
|
||||
self.config_default_values = {
|
||||
'PREVIEW_SIZE': (150, 'Preview size (100-1000)'),
|
||||
'MAX_FILE_SIZE': ('10485760', 'Max file size for image preview'),
|
||||
'ALLOW_ALL_IMAGES': (False, ''),
|
||||
'LEFTCLICK_ACTION': ('open_menuitem', 'Open'),
|
||||
'ANONYMOUS_MUC': (False, ''),
|
||||
'VERIFY': (True, ''),
|
||||
}
|
||||
|
||||
self._textviews = {}
|
||||
self._sessions = {}
|
||||
|
||||
self._orig_dir = Path(configpaths.get('MY_DATA')) / 'downloads'
|
||||
self._thumb_dir = Path(configpaths.get('MY_CACHE')) / 'downloads.thumb'
|
||||
|
||||
if GLib.mkdir_with_parents(str(self._orig_dir), 0o700) != 0:
|
||||
log.error('Failed to create: %s', self._orig_dir)
|
||||
|
||||
if GLib.mkdir_with_parents(str(self._thumb_dir), 0o700) != 0:
|
||||
log.error('Failed to create: %s', self._thumb_dir)
|
||||
|
||||
if app.settings.get('use_kib_mib'):
|
||||
self._units = GLib.FormatSizeFlags.IEC_UNITS
|
||||
else:
|
||||
self._units = GLib.FormatSizeFlags.DEFAULT
|
||||
|
||||
self._migrate_config()
|
||||
self._load_css()
|
||||
|
||||
def _migrate_config(self):
|
||||
action = self.config['LEFTCLICK_ACTION']
|
||||
if action.endswith('_menuitem'):
|
||||
self.config['LEFTCLICK_ACTION'] = action[:-9]
|
||||
|
||||
@staticmethod
|
||||
def _load_css():
|
||||
path = Path(__file__).parent / 'preview.css'
|
||||
try:
|
||||
with path.open('r') as file:
|
||||
css = file.read()
|
||||
except Exception as exc:
|
||||
log.error('Error loading css: %s', exc)
|
||||
return
|
||||
|
||||
try:
|
||||
provider = Gtk.CssProvider()
|
||||
provider.load_from_data(bytes(css.encode('utf-8')))
|
||||
Gtk.StyleContext.add_provider_for_screen(Gdk.Screen.get_default(),
|
||||
provider, 610)
|
||||
except Exception:
|
||||
log.exception('Error loading application css')
|
||||
|
||||
def _on_connect_chat_control_base(self, chat_control):
|
||||
account = chat_control.account
|
||||
if account not in self._sessions:
|
||||
self._sessions[account] = self._create_session(account)
|
||||
self._textviews[chat_control.control_id] = chat_control.conv_textview
|
||||
|
||||
def _on_disconnect_chat_control_base(self, chat_control):
|
||||
self._textviews.pop(chat_control.control_id, None)
|
||||
|
||||
def _on_connect_history_window(self, history_window):
|
||||
account = history_window.account
|
||||
if (account is not None and account not in self._sessions):
|
||||
self._sessions[account] = self._create_session(account)
|
||||
self._textviews[id(history_window)] = history_window.history_textview
|
||||
|
||||
def _on_disconnect_history_window(self, history_window):
|
||||
self._textviews.pop(id(history_window), None)
|
||||
|
||||
def _get_control_id(self, textview):
|
||||
for control_id, textview_ in self._textviews.items():
|
||||
if textview == textview_:
|
||||
return control_id
|
||||
|
||||
@staticmethod
|
||||
def _create_session(account):
|
||||
session = Soup.Session()
|
||||
session.add_feature_by_type(Soup.ContentSniffer)
|
||||
session.props.https_aliases = ['aesgcm']
|
||||
session.props.ssl_strict = False
|
||||
|
||||
proxy = get_user_proxy(account)
|
||||
if proxy is None:
|
||||
resolver = None
|
||||
else:
|
||||
resolver = proxy.get_resolver()
|
||||
|
||||
session.props.proxy_resolver = resolver
|
||||
return session, resolver
|
||||
|
||||
def _get_session(self, account):
|
||||
return self._sessions[account][0]
|
||||
|
||||
def _print_real_text(self, textview, text, _text_tags, _graphics,
|
||||
iter_, additional_data):
|
||||
|
||||
if len(text.split(' ')) > 1:
|
||||
# urlparse doesn't recognise spaces as URL delimiter
|
||||
log.debug('Text is not an uri: %s...', text[:15])
|
||||
return
|
||||
|
||||
uri = text
|
||||
urlparts = urlparse(uri)
|
||||
if not self._accept_uri(urlparts, uri, additional_data):
|
||||
return
|
||||
|
||||
textview.plugin_modified = True
|
||||
control_id = self._get_control_id(textview)
|
||||
|
||||
start_mark, end_mark = self._print_text(textview.tv.get_buffer(),
|
||||
iter_,
|
||||
uri)
|
||||
|
||||
if uri.startswith('geo:'):
|
||||
preview = self._process_geo_uri(uri,
|
||||
start_mark,
|
||||
end_mark,
|
||||
control_id,
|
||||
textview.account)
|
||||
if preview is None:
|
||||
return
|
||||
pixbuf = load_icon('map',
|
||||
size=preview.size,
|
||||
scale=get_monitor_scale_factor(),
|
||||
pixbuf=True)
|
||||
self._update_textview(preview, pixbuf)
|
||||
return
|
||||
|
||||
preview = self._process_web_uri(uri,
|
||||
urlparts,
|
||||
start_mark,
|
||||
end_mark,
|
||||
control_id,
|
||||
textview.account)
|
||||
|
||||
if not preview.orig_exists():
|
||||
self._download_content(preview)
|
||||
|
||||
elif not preview.thumb_exists():
|
||||
load_file_async(preview.orig_path,
|
||||
self._on_orig_load_finished,
|
||||
preview)
|
||||
|
||||
else:
|
||||
load_file_async(preview.thumb_path,
|
||||
self._on_thumb_load_finished,
|
||||
preview)
|
||||
|
||||
@staticmethod
|
||||
def _print_text(buffer_, iter_, text):
|
||||
if not iter_:
|
||||
iter_ = buffer_.get_end_iter()
|
||||
|
||||
start_mark = buffer_.create_mark(None, iter_, True)
|
||||
buffer_.insert_with_tags_by_name(iter_, text, 'url')
|
||||
end_mark = buffer_.create_mark(None, iter_, True)
|
||||
return start_mark, end_mark
|
||||
|
||||
def _accept_uri(self, urlparts, uri, additional_data):
|
||||
try:
|
||||
oob_url = additional_data['gajim']['oob_url']
|
||||
except (KeyError, AttributeError):
|
||||
oob_url = None
|
||||
|
||||
# geo
|
||||
if urlparts.scheme == 'geo':
|
||||
return True
|
||||
|
||||
if not urlparts.netloc:
|
||||
log.info('No netloc found in URL: %s', uri)
|
||||
return False
|
||||
|
||||
# aesgcm
|
||||
if urlparts.scheme == 'aesgcm':
|
||||
return True
|
||||
|
||||
# http/https
|
||||
if urlparts.scheme in ('https', 'http'):
|
||||
if self.config['ALLOW_ALL_IMAGES']:
|
||||
return True
|
||||
|
||||
if oob_url is None:
|
||||
log.info('No oob url for: %s', uri)
|
||||
return False
|
||||
|
||||
if uri != oob_url:
|
||||
log.info('uri != oob url: %s != %s', uri, oob_url)
|
||||
return False
|
||||
return True
|
||||
|
||||
log.info('Unsupported URI scheme: %s', uri)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _process_geo_uri(uri,
|
||||
start_mark,
|
||||
end_mark,
|
||||
control_id,
|
||||
account):
|
||||
try:
|
||||
split_geo_uri(uri)
|
||||
except Exception as err:
|
||||
log.error(uri)
|
||||
log.error(err)
|
||||
return
|
||||
|
||||
return Preview(uri,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
start_mark,
|
||||
end_mark,
|
||||
96,
|
||||
control_id,
|
||||
account)
|
||||
|
||||
def _process_web_uri(self,
|
||||
uri,
|
||||
urlparts,
|
||||
start_mark,
|
||||
end_mark,
|
||||
control_id,
|
||||
account):
|
||||
|
||||
size = self.config['PREVIEW_SIZE']
|
||||
orig_path, thumb_path = get_image_paths(uri,
|
||||
urlparts,
|
||||
size,
|
||||
self._orig_dir,
|
||||
self._thumb_dir)
|
||||
return Preview(uri,
|
||||
urlparts,
|
||||
orig_path,
|
||||
thumb_path,
|
||||
start_mark,
|
||||
end_mark,
|
||||
size,
|
||||
control_id,
|
||||
account)
|
||||
|
||||
def _on_orig_load_finished(self, data, error, preview):
|
||||
if data is None:
|
||||
log.error('%s: %s', preview.orig_path.name, error)
|
||||
return
|
||||
|
||||
preview.mime_type = self._guess_mime_type(preview.orig_path)
|
||||
preview.file_size = os.path.getsize(preview.orig_path)
|
||||
if preview.is_previewable:
|
||||
if preview.create_thumbnail(data):
|
||||
write_file_async(preview.thumb_path,
|
||||
preview.thumbnail,
|
||||
self._on_thumb_write_finished,
|
||||
preview)
|
||||
else:
|
||||
self._update_textview(preview, None)
|
||||
|
||||
def _on_thumb_load_finished(self, data, error, preview):
|
||||
if data is None:
|
||||
log.error('%s: %s', preview.thumb_path.name, error)
|
||||
return
|
||||
|
||||
preview.thumbnail = data
|
||||
preview.mime_type = self._guess_mime_type(preview.orig_path)
|
||||
preview.file_size = os.path.getsize(preview.orig_path)
|
||||
|
||||
try:
|
||||
pixbuf = pixbuf_from_data(preview.thumbnail)
|
||||
except Exception as err:
|
||||
log.error('Unable to load: %s, %s',
|
||||
preview.thumb_path.name,
|
||||
err)
|
||||
return
|
||||
self._update_textview(preview, pixbuf)
|
||||
|
||||
def _download_content(self, preview, force=False):
|
||||
if preview.account is None:
|
||||
# History Window can be opened without account context
|
||||
# This means we can not apply proxy settings
|
||||
return
|
||||
log.info('Start downloading: %s', preview.request_uri)
|
||||
message = Soup.Message.new('GET', preview.request_uri)
|
||||
message.connect('starting', self._check_certificate, preview)
|
||||
message.connect(
|
||||
'content-sniffed', self._on_content_sniffed, preview, force)
|
||||
|
||||
session = self._get_session(preview.account)
|
||||
session.queue_message(message, self._on_finished, preview)
|
||||
|
||||
def _check_certificate(self, message, preview):
|
||||
_https_used, _tls_certificate, tls_errors = message.get_https_status()
|
||||
|
||||
if not self.config['VERIFY']:
|
||||
return
|
||||
|
||||
if tls_errors:
|
||||
phrase = get_tls_error_phrase(tls_errors)
|
||||
log.warning('TLS verification failed: %s', phrase)
|
||||
session = self._get_session(preview.account)
|
||||
session.cancel_message(message, Soup.Status.CANCELLED)
|
||||
return
|
||||
|
||||
def _on_content_sniffed(self, message, type_, _params, preview, force):
|
||||
file_size = message.props.response_headers.get_content_length()
|
||||
uri = message.props.uri.to_string(False)
|
||||
session = self._get_session(preview.account)
|
||||
preview.mime_type = type_
|
||||
preview.file_size = file_size
|
||||
|
||||
if type_ not in ALLOWED_MIME_TYPES:
|
||||
log.info('Not an allowed content type: %s, %s', type_, uri)
|
||||
session.cancel_message(message, Soup.Status.CANCELLED)
|
||||
return
|
||||
|
||||
if file_size == 0 or file_size > int(self.config['MAX_FILE_SIZE']):
|
||||
log.info(
|
||||
'File size (%s) too big or unknown (zero) for URL: \'%s\'',
|
||||
file_size, uri)
|
||||
if not force:
|
||||
session.cancel_message(message, Soup.Status.CANCELLED)
|
||||
|
||||
self._update_textview(preview, None)
|
||||
|
||||
def _on_finished(self, _session, message, preview):
|
||||
if message.status_code != Soup.Status.OK:
|
||||
log.warning('Download failed: %s', preview.request_uri)
|
||||
log.warning(Soup.Status.get_phrase(message.status_code))
|
||||
return
|
||||
|
||||
data = message.props.response_body_data.get_data()
|
||||
if data is None:
|
||||
return
|
||||
|
||||
if preview.is_aes_encrypted:
|
||||
data = aes_decrypt(preview, data)
|
||||
|
||||
if preview.mime_type == 'application/octet-stream':
|
||||
preview.mime_type = self._guess_mime_type(preview.orig_path, data)
|
||||
|
||||
write_file_async(preview.orig_path,
|
||||
data,
|
||||
self._on_orig_write_finished,
|
||||
preview)
|
||||
|
||||
if preview.is_previewable:
|
||||
if preview.create_thumbnail(data):
|
||||
write_file_async(preview.thumb_path,
|
||||
preview.thumbnail,
|
||||
self._on_thumb_write_finished,
|
||||
preview)
|
||||
|
||||
def _on_orig_write_finished(self, _result, error, preview):
|
||||
if error is not None:
|
||||
log.error('%s: %s', preview.orig_path.name, error)
|
||||
return
|
||||
|
||||
log.info('File stored: %s', preview.orig_path.name)
|
||||
preview.file_size = os.path.getsize(preview.orig_path)
|
||||
if not preview.is_previewable:
|
||||
# Don’t update preview if thumb is already displayed,
|
||||
# but update preview for audio files
|
||||
self._update_textview(preview, None)
|
||||
|
||||
def _on_thumb_write_finished(self, _result, error, preview):
|
||||
if error is not None:
|
||||
log.error('%s: %s', preview.thumb_path.name, error)
|
||||
return
|
||||
|
||||
log.info('Thumbnail stored: %s ', preview.thumb_path.name)
|
||||
|
||||
try:
|
||||
pixbuf = pixbuf_from_data(preview.thumbnail)
|
||||
except Exception as err:
|
||||
log.error('Unable to load: %s, %s',
|
||||
preview.thumb_path.name,
|
||||
err)
|
||||
return
|
||||
self._update_textview(preview, pixbuf)
|
||||
|
||||
@staticmethod
|
||||
def _guess_mime_type(file_path, data=None):
|
||||
mime_type, _ = mimetypes.MimeTypes().guess_type(str(file_path))
|
||||
if mime_type is None:
|
||||
# Try to guess MIME type by file name
|
||||
mime_type, _ = Gio.content_type_guess(str(file_path), data)
|
||||
log.debug('Guessed MIME type: %s', str(mime_type))
|
||||
return mime_type
|
||||
|
||||
@staticmethod
|
||||
def _get_icon_for_mime_type(mime_type):
|
||||
if mime_type is None:
|
||||
return Gio.Icon.new_for_string('mail-attachment')
|
||||
return Gio.content_type_get_icon(mime_type)
|
||||
|
||||
def _update_textview(self, preview, data):
|
||||
textview = self._textviews.get(preview.control_id)
|
||||
if textview is None:
|
||||
# Control closed
|
||||
return
|
||||
|
||||
buffer_ = preview.start_mark.get_buffer()
|
||||
iter_ = buffer_.get_iter_at_mark(preview.start_mark)
|
||||
buffer_.insert(iter_, '\n')
|
||||
anchor = buffer_.create_child_anchor(iter_)
|
||||
anchor.plaintext = preview.uri
|
||||
|
||||
preview_widget = self._create_preview_widget(preview, data)
|
||||
|
||||
textview.tv.add_child_at_anchor(preview_widget, anchor)
|
||||
buffer_.delete(iter_,
|
||||
buffer_.get_iter_at_mark(preview.end_mark))
|
||||
|
||||
if textview.autoscroll:
|
||||
textview.scroll_to_end()
|
||||
|
||||
def _create_preview_widget(self, preview, data):
|
||||
if isinstance(data, GdkPixbuf.PixbufAnimation):
|
||||
image = Gtk.Image.new_from_animation(data)
|
||||
elif isinstance(data, GdkPixbuf.Pixbuf):
|
||||
image = Gtk.Image.new_from_pixbuf(data)
|
||||
else:
|
||||
icon = self._get_icon_for_mime_type(preview.mime_type)
|
||||
image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.DIALOG)
|
||||
|
||||
path = self.local_file_path('preview.ui')
|
||||
ui = get_builder(path)
|
||||
ui.connect_signals(self)
|
||||
|
||||
ui.download_button.set_no_show_all(True)
|
||||
ui.download_button.connect('clicked', self._on_download, preview)
|
||||
ui.save_as_button.set_no_show_all(True)
|
||||
ui.save_as_button.connect('clicked', self._on_save_as, preview)
|
||||
ui.open_folder_button.set_no_show_all(True)
|
||||
ui.open_folder_button.connect('clicked', self._on_open_folder, preview)
|
||||
|
||||
ui.image_button.add(image)
|
||||
ui.image_button.set_tooltip_text(preview.filename)
|
||||
ui.image_button.connect('clicked',
|
||||
self._on_image_button_clicked,
|
||||
preview)
|
||||
ui.image_button.connect('button_press_event',
|
||||
self._on_button_press_event,
|
||||
preview)
|
||||
|
||||
ui.preview_box.show_all()
|
||||
|
||||
if preview.is_geo_uri:
|
||||
ui.file_name.set_text(_('Click to view location'))
|
||||
ui.save_as_button.hide()
|
||||
ui.open_folder_button.hide()
|
||||
ui.download_button.hide()
|
||||
location = split_geo_uri(preview.uri)
|
||||
ui.file_size.set_text(_('Lat: %s Lon: %s') % (
|
||||
location.lat, location.lon))
|
||||
ui.image_button.set_tooltip_text(
|
||||
_('Location at Lat: %s Lon: %s') % (
|
||||
location.lat, location.lon))
|
||||
ui.image_event_box.set_halign(Gtk.Align.CENTER)
|
||||
ui.preview_box.set_size_request(160, -1)
|
||||
return ui.preview_box
|
||||
|
||||
if preview.is_previewable and preview.orig_exists():
|
||||
ui.image_event_box.set_halign(Gtk.Align.CENTER)
|
||||
else:
|
||||
image.set_property('pixel-size', 64)
|
||||
|
||||
if preview.orig_exists():
|
||||
ui.download_button.hide()
|
||||
if (preview.is_audio and app.is_installed('GST') and
|
||||
self._contains_audio_streams(preview.orig_path)):
|
||||
audio_widget = AudioWidget(preview.orig_path)
|
||||
ui.preview_box.pack_start(audio_widget, True, True, 0)
|
||||
else:
|
||||
ui.save_as_button.hide()
|
||||
ui.open_folder_button.hide()
|
||||
|
||||
file_size_string = _('File size unknown')
|
||||
if preview.file_size != 0:
|
||||
file_size_string = GLib.format_size_full(
|
||||
preview.file_size, self._units)
|
||||
ui.file_size.set_text(file_size_string)
|
||||
|
||||
ui.preview_box.set_size_request(300, -1)
|
||||
ui.file_name.set_text(preview.filename)
|
||||
ui.file_name.set_tooltip_text(preview.filename)
|
||||
|
||||
return ui.preview_box
|
||||
|
||||
def _get_context_menu(self, preview):
|
||||
def destroy(menu, _pspec):
|
||||
visible = menu.get_property('visible')
|
||||
if not visible:
|
||||
GLib.idle_add(menu.destroy)
|
||||
|
||||
path = self.local_file_path('context_menu.ui')
|
||||
ui = get_builder(path)
|
||||
|
||||
ui.download.connect(
|
||||
'activate', self._on_download, preview)
|
||||
ui.open.connect(
|
||||
'activate', self._on_open, preview)
|
||||
ui.save_as.connect(
|
||||
'activate', self._on_save_as, preview)
|
||||
ui.open_folder.connect(
|
||||
'activate', self._on_open_folder, preview)
|
||||
ui.open_link_in_browser.connect(
|
||||
'activate', self._on_open_link_in_browser, preview)
|
||||
ui.copy_link_location.connect(
|
||||
'activate', self._on_copy_link_location, preview)
|
||||
ui.context_menu.connect('notify::visible', destroy)
|
||||
|
||||
if preview.is_aes_encrypted:
|
||||
ui.open_link_in_browser.hide()
|
||||
|
||||
if preview.is_geo_uri:
|
||||
ui.download.hide()
|
||||
ui.open_link_in_browser.hide()
|
||||
ui.save_as.hide()
|
||||
ui.open_folder.hide()
|
||||
return ui.context_menu
|
||||
|
||||
if preview.orig_exists():
|
||||
ui.download.hide()
|
||||
else:
|
||||
ui.open.hide()
|
||||
ui.save_as.hide()
|
||||
ui.open_folder.hide()
|
||||
|
||||
return ui.context_menu
|
||||
|
||||
def _on_download(self, _menu, preview):
|
||||
if not preview.orig_exists():
|
||||
self._download_content(preview, force=True)
|
||||
|
||||
def _on_open(self, _menu, preview):
|
||||
if preview.is_geo_uri:
|
||||
open_uri(preview.uri)
|
||||
return
|
||||
|
||||
if not preview.orig_exists():
|
||||
self._download_content(preview, force=True)
|
||||
return
|
||||
|
||||
open_file(preview.orig_path)
|
||||
|
||||
def _on_save_as(self, _menu, preview):
|
||||
def on_ok(target_path):
|
||||
dirname = Path(target_path).parent
|
||||
if not os.access(dirname, os.W_OK):
|
||||
ErrorDialog(
|
||||
_('Directory \'%s\' is not writable') % dirname,
|
||||
_('You do not have the proper permissions to '
|
||||
'create files in this directory.'),
|
||||
transient_for=app.app.get_active_window())
|
||||
return
|
||||
shutil.copyfile(str(preview.orig_path), target_path)
|
||||
|
||||
if not preview.orig_exists():
|
||||
self._download_content(preview, force=True)
|
||||
return
|
||||
|
||||
FileSaveDialog(on_ok,
|
||||
path=app.settings.get('last_save_dir'),
|
||||
file_name=preview.filename,
|
||||
transient_for=app.app.get_active_window())
|
||||
|
||||
def _on_open_folder(self, _menu, preview):
|
||||
if not preview.orig_exists():
|
||||
self._download_content(preview, force=True)
|
||||
return
|
||||
open_file(preview.orig_path.parent)
|
||||
|
||||
@staticmethod
|
||||
def _on_copy_link_location(_menu, preview):
|
||||
clipboard = Gtk.Clipboard.get_default(Gdk.Display.get_default())
|
||||
clipboard.set_text(preview.uri, -1)
|
||||
|
||||
@staticmethod
|
||||
def _on_open_link_in_browser(_menu, preview):
|
||||
if preview.is_aes_encrypted:
|
||||
if preview.is_geo_uri:
|
||||
open_uri(preview.uri)
|
||||
return
|
||||
open_file(preview.orig_path)
|
||||
else:
|
||||
open_uri(preview.uri)
|
||||
|
||||
def _on_image_button_clicked(self, _button, preview):
|
||||
action = self.config['LEFTCLICK_ACTION']
|
||||
method = getattr(self, '_on_%s' % action)
|
||||
method(None, preview)
|
||||
|
||||
def _on_button_press_event(self, _button, event, preview):
|
||||
if event.type == Gdk.EventType.BUTTON_PRESS and event.button == 3:
|
||||
# Right klick
|
||||
menu = self._get_context_menu(preview)
|
||||
menu.popup_at_pointer(event)
|
||||
|
||||
@staticmethod
|
||||
def _contains_audio_streams(file_path):
|
||||
# Check if it is really an audio file
|
||||
has_audio = False
|
||||
discoverer = GstPbutils.Discoverer()
|
||||
try:
|
||||
info = discoverer.discover_uri(f'file://{file_path}')
|
||||
has_audio = bool(info.get_audio_streams())
|
||||
except GLib.Error as err:
|
||||
log.error('Error while reading %s: %s', str(file_path), err)
|
||||
return False
|
||||
if not has_audio:
|
||||
log.warning('File does not contain audio stream: %s',
|
||||
str(file_path))
|
||||
return has_audio
|
||||
|
||||
@staticmethod
|
||||
def _on_realize(event_box):
|
||||
event_box.get_window().set_cursor(get_cursor('pointer'))
|
||||
|
||||
|
||||
class Preview:
|
||||
def __init__(self, uri, urlparts, orig_path, thumb_path,
|
||||
start_mark, end_mark, size, control_id, account):
|
||||
self._uri = uri
|
||||
self._urlparts = urlparts
|
||||
self._filename = filename_from_uri(self._uri)
|
||||
|
||||
self.size = size
|
||||
self.control_id = control_id
|
||||
self.orig_path = orig_path
|
||||
self.thumb_path = thumb_path
|
||||
self.start_mark = start_mark
|
||||
self.end_mark = end_mark
|
||||
self.account = account
|
||||
|
||||
self.thumbnail = None
|
||||
self.mime_type = None
|
||||
self.file_size = 0
|
||||
|
||||
self.key, self.iv = None, None
|
||||
if self.is_aes_encrypted:
|
||||
self.key, self.iv = parse_fragment(urlparts.fragment)
|
||||
|
||||
@property
|
||||
def is_geo_uri(self):
|
||||
return self._uri.startswith('geo:')
|
||||
|
||||
@property
|
||||
def is_web_uri(self):
|
||||
return not self.is_geo_uri
|
||||
|
||||
@property
|
||||
def is_previewable(self):
|
||||
return self.mime_type in PREVIEWABLE_MIME_TYPES
|
||||
|
||||
@property
|
||||
def is_audio(self):
|
||||
is_allowed = bool(self.mime_type in ALLOWED_MIME_TYPES)
|
||||
return is_allowed and self.mime_type.startswith('audio/')
|
||||
|
||||
@property
|
||||
def uri(self):
|
||||
return self._uri
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
return self._filename
|
||||
|
||||
@property
|
||||
def request_uri(self):
|
||||
if self.is_aes_encrypted:
|
||||
# Remove fragments so we dont transmit it to the server
|
||||
urlparts = self._urlparts._replace(scheme='https', fragment='')
|
||||
return urlparts.geturl()
|
||||
return self._urlparts.geturl()
|
||||
|
||||
@property
|
||||
def is_aes_encrypted(self):
|
||||
if self._urlparts is None:
|
||||
return False
|
||||
return self._urlparts.scheme == 'aesgcm'
|
||||
|
||||
def thumb_exists(self):
|
||||
return self.thumb_path.exists()
|
||||
|
||||
def orig_exists(self):
|
||||
return self.orig_path.exists()
|
||||
|
||||
def create_thumbnail(self, data):
|
||||
self.thumbnail = create_thumbnail(data, self.size)
|
||||
if self.thumbnail is None:
|
||||
log.warning('Creating thumbnail failed for: %s', self.orig_path)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class AudioWidget(Gtk.Box):
|
||||
def __init__(self, file_path):
|
||||
Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL,
|
||||
spacing=6)
|
||||
self._playbin = None
|
||||
self._query = None
|
||||
self._has_timeout = False
|
||||
|
||||
self._build_audio_widget()
|
||||
self._setup_audio_player(file_path)
|
||||
|
||||
def _build_audio_widget(self):
|
||||
play_button = Gtk.Button()
|
||||
play_button.get_style_context().add_class('flat')
|
||||
play_button.get_style_context().add_class('preview-button')
|
||||
play_button.set_tooltip_text(_('Start/stop playback'))
|
||||
self._play_icon = Gtk.Image.new_from_icon_name(
|
||||
'media-playback-start-symbolic',
|
||||
Gtk.IconSize.BUTTON)
|
||||
play_button.add(self._play_icon)
|
||||
play_button.connect('clicked', self._on_play_clicked)
|
||||
event_box = Gtk.EventBox()
|
||||
event_box.connect('realize', self._on_realize)
|
||||
event_box.add(play_button)
|
||||
self.add(event_box)
|
||||
|
||||
self._seek_bar = Gtk.Scale(
|
||||
orientation=Gtk.Orientation.HORIZONTAL)
|
||||
self._seek_bar.set_range(0.0, 1.0)
|
||||
self._seek_bar.set_hexpand(True)
|
||||
self._seek_bar.set_value_pos(Gtk.PositionType.RIGHT)
|
||||
self._seek_bar.connect('change-value', self._on_seek)
|
||||
self._seek_bar.connect(
|
||||
'format-value', self._format_audio_timestamp)
|
||||
event_box = Gtk.EventBox()
|
||||
event_box.connect('realize', self._on_realize)
|
||||
event_box.add(self._seek_bar)
|
||||
self.add(event_box)
|
||||
|
||||
self.connect('destroy', self._on_destroy)
|
||||
self.show_all()
|
||||
|
||||
def _setup_audio_player(self, file_path):
|
||||
self._playbin = Gst.ElementFactory.make('playbin', 'bin')
|
||||
if self._playbin is None:
|
||||
return
|
||||
self._playbin.set_property(
|
||||
'uri', f'file://{file_path}')
|
||||
state_return = self._playbin.set_state(Gst.State.PAUSED)
|
||||
if state_return == Gst.StateChangeReturn.FAILURE:
|
||||
return
|
||||
|
||||
self._query = Gst.Query.new_position(Gst.Format.TIME)
|
||||
bus = self._playbin.get_bus()
|
||||
bus.add_signal_watch()
|
||||
bus.connect('message', self._on_bus_message)
|
||||
|
||||
def _on_bus_message(self, _bus, message):
|
||||
if message.type == Gst.MessageType.EOS:
|
||||
self._set_pause(True)
|
||||
self._playbin.seek_simple(
|
||||
Gst.Format.TIME, Gst.SeekFlags.FLUSH, 0)
|
||||
elif message.type == Gst.MessageType.STATE_CHANGED:
|
||||
_success, duration = self._playbin.query_duration(
|
||||
Gst.Format.TIME)
|
||||
if duration > 0:
|
||||
self._seek_bar.set_range(0.0, duration)
|
||||
|
||||
is_paused = self._get_paused()
|
||||
if (duration > 0 and not is_paused and
|
||||
not self._has_timeout):
|
||||
GLib.timeout_add(500, self._update_seek_bar)
|
||||
self._has_timeout = True
|
||||
|
||||
def _on_seek(self, _range, _scroll, value):
|
||||
self._playbin.seek_simple(
|
||||
Gst.Format.TIME, Gst.SeekFlags.FLUSH, value)
|
||||
return False
|
||||
|
||||
def _on_play_clicked(self, _button):
|
||||
self._set_pause(not self._get_paused())
|
||||
|
||||
def _on_destroy(self, _widget):
|
||||
self._playbin.set_state(Gst.State.NULL)
|
||||
|
||||
def _get_paused(self):
|
||||
_, state, _ = self._playbin.get_state(20)
|
||||
return state == Gst.State.PAUSED
|
||||
|
||||
def _set_pause(self, paused):
|
||||
if paused:
|
||||
self._playbin.set_state(Gst.State.PAUSED)
|
||||
self._play_icon.set_from_icon_name(
|
||||
'media-playback-start-symbolic',
|
||||
Gtk.IconSize.BUTTON)
|
||||
else:
|
||||
self._playbin.set_state(Gst.State.PLAYING)
|
||||
self._play_icon.set_from_icon_name(
|
||||
'media-playback-pause-symbolic',
|
||||
Gtk.IconSize.BUTTON)
|
||||
|
||||
def _update_seek_bar(self):
|
||||
if self._get_paused():
|
||||
self._has_timeout = False
|
||||
return False
|
||||
|
||||
if self._playbin.query(self._query):
|
||||
_fmt, cur_pos = self._query.parse_position()
|
||||
self._seek_bar.set_value(cur_pos)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _format_audio_timestamp(_widget, ns):
|
||||
seconds = ns / 1000000000
|
||||
minutes = seconds / 60
|
||||
hours = minutes / 60
|
||||
|
||||
i_seconds = int(seconds) % 60
|
||||
i_minutes = int(minutes) % 60
|
||||
i_hours = int(hours)
|
||||
|
||||
if i_hours > 0:
|
||||
return f'{i_hours:d}:{i_minutes:02d}:{i_seconds:02d}'
|
||||
return f'{i_minutes:d}:{i_seconds:02d}'
|
||||
|
||||
@staticmethod
|
||||
def _on_realize(event_box):
|
||||
event_box.get_window().set_cursor(get_cursor('pointer'))
|
||||
@@ -1,333 +0,0 @@
|
||||
# This file is part of Image Preview Gajim Plugin.
|
||||
#
|
||||
# Image Preview Gajim Plugin is free software;
|
||||
# you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation; version 3 only.
|
||||
#
|
||||
# Image Preview Gajim Plugin is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Image Preview Gajim Plugin.
|
||||
# If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import math
|
||||
import logging
|
||||
import binascii
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
from collections import namedtuple
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import unquote
|
||||
|
||||
from gi.repository import GdkPixbuf
|
||||
from gi.repository import GLib
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher
|
||||
from cryptography.hazmat.primitives.ciphers import algorithms
|
||||
from cryptography.hazmat.primitives.ciphers.modes import GCM
|
||||
|
||||
|
||||
log = logging.getLogger('gajim.p.preview.utils')
|
||||
|
||||
Coords = namedtuple('Coords', 'location lat lon')
|
||||
|
||||
|
||||
def resize_gif(image, output_file, resize_to):
|
||||
frames, result = extract_and_resize_frames(image, resize_to)
|
||||
|
||||
frames[0].save(output_file,
|
||||
format='GIF',
|
||||
optimize=True,
|
||||
save_all=True,
|
||||
append_images=frames[1:],
|
||||
duration=result['duration'],
|
||||
loop=1000)
|
||||
|
||||
|
||||
def analyse_image(image):
|
||||
'''
|
||||
Pre-process pass over the image to determine the mode (full or additive).
|
||||
Necessary as assessing single frames isn't reliable. Need to know the mode
|
||||
before processing all frames.
|
||||
'''
|
||||
|
||||
result = {
|
||||
'size': image.size,
|
||||
'mode': 'full',
|
||||
'duration': image.info.get('duration', 0)
|
||||
}
|
||||
|
||||
try:
|
||||
while True:
|
||||
if image.tile:
|
||||
tile = image.tile[0]
|
||||
update_region = tile[1]
|
||||
update_region_dimensions = update_region[2:]
|
||||
if update_region_dimensions != image.size:
|
||||
result['mode'] = 'partial'
|
||||
break
|
||||
image.seek(image.tell() + 1)
|
||||
except EOFError:
|
||||
image.seek(0)
|
||||
return image, result
|
||||
|
||||
|
||||
def extract_and_resize_frames(image, resize_to):
|
||||
image, result = analyse_image(image)
|
||||
|
||||
i = 0
|
||||
palette = image.getpalette()
|
||||
last_frame = image.convert('RGBA')
|
||||
|
||||
frames = []
|
||||
|
||||
try:
|
||||
while True:
|
||||
'''
|
||||
If the GIF uses local colour tables,
|
||||
each frame will have its own palette.
|
||||
If not, we need to apply the global palette to the new frame.
|
||||
'''
|
||||
if not image.getpalette():
|
||||
image.putpalette(palette)
|
||||
|
||||
new_frame = Image.new('RGBA', image.size)
|
||||
|
||||
'''
|
||||
Is this file a "partial"-mode GIF where frames update a region
|
||||
of a different size to the entire image?
|
||||
If so, we need to construct the new frame by
|
||||
pasting it on top of the preceding frames.
|
||||
'''
|
||||
if result['mode'] == 'partial':
|
||||
new_frame.paste(last_frame)
|
||||
|
||||
new_frame.paste(image, (0, 0), image.convert('RGBA'))
|
||||
|
||||
# This method preservs aspect ratio
|
||||
new_frame.thumbnail(resize_to, Image.ANTIALIAS)
|
||||
frames.append(new_frame)
|
||||
|
||||
i += 1
|
||||
last_frame = new_frame
|
||||
image.seek(image.tell() + 1)
|
||||
except EOFError:
|
||||
pass
|
||||
|
||||
return frames, result
|
||||
|
||||
|
||||
def create_thumbnail(data, size):
|
||||
thumbnail = create_thumbnail_with_pil(data, size)
|
||||
if thumbnail is not None:
|
||||
return thumbnail
|
||||
return create_thumbnail_with_pixbuf(data, size)
|
||||
|
||||
|
||||
def create_thumbnail_with_pixbuf(data, size):
|
||||
loader = GdkPixbuf.PixbufLoader()
|
||||
try:
|
||||
loader.write(data)
|
||||
loader.close()
|
||||
except GLib.Error as error:
|
||||
log.warning('making pixbuf failed: %s', error)
|
||||
return None
|
||||
|
||||
pixbuf = loader.get_pixbuf()
|
||||
|
||||
if size > pixbuf.get_width() and size > pixbuf.get_height():
|
||||
return data
|
||||
|
||||
width, height = get_thumbnail_size(pixbuf, size)
|
||||
thumbnail = pixbuf.scale_simple(width,
|
||||
height,
|
||||
GdkPixbuf.InterpType.BILINEAR)
|
||||
try:
|
||||
_error, bytes_ = thumbnail.save_to_bufferv('png', [], [])
|
||||
except GLib.Error as err:
|
||||
log.warning('Saving pixbuf to buffer failed: %s', err)
|
||||
return None
|
||||
return bytes_
|
||||
|
||||
|
||||
def create_thumbnail_with_pil(data, size):
|
||||
input_file = BytesIO(data)
|
||||
output_file = BytesIO()
|
||||
try:
|
||||
image = Image.open(input_file)
|
||||
except OSError as error:
|
||||
log.warning('making pil thumbnail failed: %s', error)
|
||||
log.warning('fallback to pixbuf')
|
||||
input_file.close()
|
||||
output_file.close()
|
||||
return
|
||||
|
||||
image_width, image_height = image.size
|
||||
if size > image_width and size > image_height:
|
||||
image.close()
|
||||
input_file.close()
|
||||
output_file.close()
|
||||
return data
|
||||
|
||||
if image.format == 'GIF' and image.n_frames > 1:
|
||||
resize_gif(image, output_file, (size, size))
|
||||
else:
|
||||
image.thumbnail((size, size))
|
||||
image.save(output_file,
|
||||
format=image.format,
|
||||
exif=image.info.get('exif', b''),
|
||||
optimize=True)
|
||||
|
||||
bytes_ = output_file.getvalue()
|
||||
|
||||
image.close()
|
||||
input_file.close()
|
||||
output_file.close()
|
||||
|
||||
return bytes_
|
||||
|
||||
|
||||
def get_thumbnail_size(pixbuf, size):
|
||||
# Calculates the new thumbnail size while preserving the aspect ratio
|
||||
image_width = pixbuf.get_width()
|
||||
image_height = pixbuf.get_height()
|
||||
|
||||
if image_width > image_height:
|
||||
if image_width > size:
|
||||
image_height = math.ceil((size / float(image_width) * image_height))
|
||||
image_width = int(size)
|
||||
else:
|
||||
if image_height > size:
|
||||
image_width = math.ceil((size / float(image_height) * image_width))
|
||||
image_height = int(size)
|
||||
|
||||
return image_width, image_height
|
||||
|
||||
|
||||
def pixbuf_from_data(data):
|
||||
loader = GdkPixbuf.PixbufLoader()
|
||||
try:
|
||||
loader.write(data)
|
||||
loader.close()
|
||||
except GLib.Error:
|
||||
# Fallback to Pillow
|
||||
input_file = BytesIO(data)
|
||||
image = Image.open(BytesIO(data)).convert('RGBA')
|
||||
array = GLib.Bytes.new(image.tobytes())
|
||||
width, height = image.size
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(array,
|
||||
GdkPixbuf.Colorspace.RGB,
|
||||
True,
|
||||
8,
|
||||
width,
|
||||
height,
|
||||
width * 4)
|
||||
image.close()
|
||||
input_file.close()
|
||||
return pixbuf
|
||||
|
||||
return loader.get_pixbuf().apply_embedded_orientation()
|
||||
|
||||
|
||||
def parse_fragment(fragment):
|
||||
if not fragment:
|
||||
raise ValueError('Invalid fragment')
|
||||
|
||||
fragment = binascii.unhexlify(fragment)
|
||||
size = len(fragment)
|
||||
# Clients started out with using a 16 byte IV but long term
|
||||
# want to swtich to the more performant 12 byte IV
|
||||
# We have to support both
|
||||
if size == 48:
|
||||
key = fragment[16:]
|
||||
iv = fragment[:16]
|
||||
elif size == 44:
|
||||
key = fragment[12:]
|
||||
iv = fragment[:12]
|
||||
else:
|
||||
raise ValueError('Invalid fragment size: %s' % size)
|
||||
|
||||
return key, iv
|
||||
|
||||
|
||||
def get_image_paths(uri, urlparts, size, orig_dir, thumb_dir):
|
||||
path = Path(unquote(urlparts.path))
|
||||
web_stem = path.stem
|
||||
extension = path.suffix
|
||||
|
||||
if len(web_stem) > 90:
|
||||
# Many Filesystems have a limit on filename length
|
||||
# Most have 255, some encrypted ones only 143
|
||||
# We add around 50 chars for the hash,
|
||||
# so the filename should not exceed 90
|
||||
web_stem = web_stem[:90]
|
||||
|
||||
name_hash = hashlib.sha1(str(uri).encode()).hexdigest()
|
||||
|
||||
orig_filename = '%s_%s%s' % (web_stem, name_hash, extension)
|
||||
|
||||
thumb_filename = '%s_%s_thumb_%s%s' % (web_stem,
|
||||
name_hash,
|
||||
size,
|
||||
extension)
|
||||
|
||||
orig_path = orig_dir / orig_filename
|
||||
thumb_path = thumb_dir / thumb_filename
|
||||
return orig_path, thumb_path
|
||||
|
||||
|
||||
def split_geo_uri(uri):
|
||||
# Example:
|
||||
# geo:37.786971,-122.399677,122.3;CRS=epsg:32718;U=20;mapcolors=abc
|
||||
# Assumption is all coordinates are CRS=WGS-84
|
||||
|
||||
# Remove "geo:"
|
||||
coords = uri[4:]
|
||||
|
||||
# Remove arguments
|
||||
if ';' in coords:
|
||||
coords, _ = coords.split(';', maxsplit=1)
|
||||
|
||||
# Split coords
|
||||
coords = coords.split(',')
|
||||
if len(coords) not in (2, 3):
|
||||
raise ValueError('Invalid geo uri: invalid coord count')
|
||||
|
||||
# Remoove coord-c (altitude)
|
||||
if len(coords) == 3:
|
||||
coords.pop(2)
|
||||
|
||||
lat, lon = coords
|
||||
if float(lat) < -90 or float(lat) > 90:
|
||||
raise ValueError('Invalid geo_uri: invalid latitude %s' % lat)
|
||||
|
||||
if float(lon) < -180 or float(lon) > 180:
|
||||
raise ValueError('Invalid geo_uri: invalid longitude %s' % lon)
|
||||
|
||||
location = ','.join(coords)
|
||||
return Coords(location=location, lat=lat, lon=lon)
|
||||
|
||||
|
||||
def filename_from_uri(uri):
|
||||
urlparts = urlparse(unquote(uri))
|
||||
path = Path(urlparts.path)
|
||||
return path.name
|
||||
|
||||
|
||||
def aes_decrypt(preview, payload):
|
||||
# Use AES128 GCM with the given key and iv to decrypt the payload
|
||||
data = payload[:-16]
|
||||
tag = payload[-16:]
|
||||
decryptor = Cipher(
|
||||
algorithms.AES(preview.key),
|
||||
GCM(preview.iv, tag=tag),
|
||||
backend=default_backend()).decryptor()
|
||||
return decryptor.update(data) + decryptor.finalize()
|
||||
Reference in New Issue
Block a user