Merge branch 'develop' of github.com:martini/zammad into develop

This commit is contained in:
Felix Niklas 2015-07-20 12:57:18 +02:00
commit db863f7b1b
44 changed files with 1992 additions and 407 deletions

View file

@ -286,16 +286,24 @@ class App.ControllerTabs extends App.Controller
tabs: @tabs
)
# insert content
for tab in @tabs
@el.find('.tab-content').append('<div class="tab-pane" id="' + tab.target + '"></div>')
@el.find('.tab-content').append("<div class=\"tab-pane\" id=\"#{tab.target}\"></div>")
if tab.controller
params = tab.params || {}
params.el = @el.find( '#' + tab.target )
params.name = tab.name
params.target = tab.target
params.el = @el.find( "##{tab.target}" )
new tab.controller( params )
# check if tabs need to be hidden
if @tabs.length <= 1
@el.find('.nav-tabs').addClass('hide')
# set last or first tab to active
@lastActiveTab = @Config.get('lastTab')
if @lastActiveTab && @el.find('.nav-tabs li a[href="' + @lastActiveTab + '"]')[0]
@el.find('.nav-tabs li a[href="' + @lastActiveTab + '"]').tab('show')
if @lastActiveTab && @el.find(".nav-tabs li a[href=#{@lastActiveTab}]")[0]
@el.find(".nav-tabs li a[href=#{@lastActiveTab}]").tab('show')
else
@el.find('.nav-tabs li:first a').tab('show')

View file

@ -26,21 +26,30 @@ class App.SettingsArea extends App.Controller
area: @area
)
# filter online service settings
if App.Config.get('system_online_service')
settings = _.filter(settings, (setting) ->
return if setting.online_service
return if setting.preferences && setting.preferences.online_service_disable
setting
)
return if _.isEmpty(settings)
# sort by prio
settings = _.sortBy( settings, (setting) ->
return if !setting.preferences
setting.preferences.prio
)
html = $('<div></div>')
elements = []
for setting in settings
if setting.name is 'product_logo'
item = new App.SettingsAreaLogo( setting: setting )
else
item = new App.SettingsAreaItem( setting: setting )
html.append( item.el )
elements.push item.el
@html html
@html elements
class App.SettingsAreaItem extends App.Controller
events:
@ -67,13 +76,13 @@ class App.SettingsAreaItem extends App.Controller
# item
@html App.view('settings/item')(
setting: @setting,
setting: @setting
)
new App.ControllerForm(
el: @el.find('.form-item'),
model: { configure_attributes: @configure_attributes, className: '' },
autofocus: false,
model: { configure_attributes: @configure_attributes, className: '' }
autofocus: false
)
update: (e) =>
@ -103,7 +112,6 @@ class App.SettingsAreaItem extends App.Controller
@setting.save(
done: =>
ui.formEnable(e)
App.Event.trigger 'notify', {
type: 'success'
msg: App.i18n.translateContent('Update successful!')
@ -112,7 +120,6 @@ class App.SettingsAreaItem extends App.Controller
# rerender ui || get new collections and session data
if @setting.preferences
if @setting.preferences.render
ui.render()
App.Event.trigger( 'ui:rerender' )
@ -121,6 +128,11 @@ class App.SettingsAreaItem extends App.Controller
App.Auth.loginCheck()
fail: =>
ui.formEnable(e)
App.Event.trigger 'notify', {
type: 'error'
msg: App.i18n.translateContent('Can\'t update item!')
timeout: 2000
}
)
class App.SettingsAreaLogo extends App.Controller

View file

@ -5,7 +5,7 @@ class Branding extends App.ControllerTabs
return if !@authenticate()
@title 'Branding', true
@tabs = [
{ name: 'Base', 'target': 'base', controller: App.SettingsArea, params: { area: 'System::Branding' } },
{ name: 'Base', 'target': 'base', controller: App.SettingsArea, params: { area: 'System::Branding' } }
]
@render()
@ -15,12 +15,13 @@ class System extends App.ControllerTabs
super
return if !@authenticate()
@title 'System', true
@tabs = [
{ name: 'Base', 'target': 'base', controller: App.SettingsArea, params: { area: 'System::Base' } },
{ name: 'Storage', 'target': 'storage', controller: App.SettingsArea, params: { area: 'System::Storage' } },
{ name: 'Geo Services', 'target': 'geo', controller: App.SettingsArea, params: { area: 'System::Geo' } },
{ name: 'Frontend', 'target': 'ui', controller: App.SettingsArea, params: { area: 'System::UI' } },
]
@tabs = []
if !App.Config.get('system_online_service')
@tabs.push { name: 'Base', 'target': 'base', controller: App.SettingsArea, params: { area: 'System::Base' } }
@tabs.push { name: 'Services', 'target': 'services', controller: App.SettingsArea, params: { area: 'System::Services' } }
if !App.Config.get('system_online_service')
@tabs.push { name: 'Storage', 'target': 'storage', controller: App.SettingsArea, params: { area: 'System::Storage' } }
@tabs.push { name: 'Frontend', 'target': 'ui', controller: App.SettingsArea, params: { area: 'System::UI' } }
@render()
class Security extends App.ControllerTabs
@ -30,11 +31,10 @@ class Security extends App.ControllerTabs
return if !@authenticate()
@title 'Security', true
@tabs = [
{ name: 'Base', 'target': 'base', controller: App.SettingsArea, params: { area: 'Security::Base' } },
# { name: 'Authentication', 'target': 'auth', controller: App.SettingsArea, params: { area: 'Security::Authentication' } },
{ name: 'Password', 'target': 'password', controller: App.SettingsArea, params: { area: 'Security::Password' } },
{ name: 'Third-Party Applications', 'target': 'third_party_auth', controller: App.SettingsArea, params: { area: 'Security::ThirdPartyAuthentication' } },
# { name: 'Session', 'target': 'session', controller: '' },
{ name: 'Base', 'target': 'base', controller: App.SettingsArea, params: { area: 'Security::Base' } }
# { name: 'Authentication', 'target': 'auth', controller: App.SettingsArea, params: { area: 'Security::Authentication' } }
{ name: 'Password', 'target': 'password', controller: App.SettingsArea, params: { area: 'Security::Password' } }
{ name: 'Third-Party Applications', 'target': 'third_party_auth', controller: App.SettingsArea, params: { area: 'Security::ThirdPartyAuthentication' } }
]
@render()
@ -45,8 +45,8 @@ class Import extends App.ControllerTabs
return if !@authenticate()
@title 'Import', true
@tabs = [
{ name: 'Base', 'target': 'base', controller: App.SettingsArea, params: { area: 'Import::Base' } },
{ name: 'OTRS', 'target': 'otrs', controller: App.SettingsArea, params: { area: 'Import::OTRS' } },
{ name: 'Base', 'target': 'base', controller: App.SettingsArea, params: { area: 'Import::Base' } }
{ name: 'OTRS', 'target': 'otrs', controller: App.SettingsArea, params: { area: 'Import::OTRS' } }
]
@render()
@ -57,15 +57,14 @@ class Ticket extends App.ControllerTabs
return if !@authenticate()
@title 'Ticket', true
@tabs = [
{ name: 'Base', 'target': 'base', controller: App.SettingsArea, params: { area: 'Ticket::Base' } },
{ name: 'Number', 'target': 'number', controller: App.SettingsArea, params: { area: 'Ticket::Number' } },
# { name: 'Sender Format', 'target': 'sender-format', controller: App.SettingsArea, params: { area: 'Ticket::SenderFormat' } },
{ name: 'Base', 'target': 'base', controller: App.SettingsArea, params: { area: 'Ticket::Base' } }
{ name: 'Number', 'target': 'number', controller: App.SettingsArea, params: { area: 'Ticket::Number' } }
]
@render()
App.Config.set( 'SettingBranding', { prio: 1200, parent: '#settings', name: 'Branding', target: '#settings/branding', controller: Branding, role: ['Admin'] }, 'NavBarAdmin' )
App.Config.set( 'SettingSystem', { prio: 1400, parent: '#settings', name: 'System', target: '#settings/system', controller: System, role: ['Admin'] }, 'NavBarAdmin' )
App.Config.set( 'SettingSecurity', { prio: 1500, parent: '#settings', name: 'Security', target: '#settings/security', controller: Security, role: ['Admin'] }, 'NavBarAdmin' )
App.Config.set( 'SettingTicket', { prio: 1600, parent: '#settings', name: 'Ticket', target: '#settings/ticket', controller: Ticket, role: ['Admin'] }, 'NavBarAdmin' )
App.Config.set( 'SettingImport', { prio: 1700, parent: '#settings', name: 'Import', target: '#settings/import', controller: Import, role: ['Admin'] }, 'NavBarAdmin' )
App.Config.set( 'SettingSystem', { prio: 1400, parent: '#settings', name: 'System', target: '#settings/system', controller: System, role: ['Admin'] }, 'NavBarAdmin' )
App.Config.set( 'SettingSecurity', { prio: 1600, parent: '#settings', name: 'Security', target: '#settings/security', controller: Security, role: ['Admin'] }, 'NavBarAdmin' )
App.Config.set( 'SettingTicket', { prio: 1700, parent: '#settings', name: 'Ticket', target: '#settings/ticket', controller: Ticket, role: ['Admin'] }, 'NavBarAdmin' )
App.Config.set( 'SettingImport', { prio: 1800, parent: '#settings', name: 'Import', target: '#settings/import', controller: Import, role: ['Admin'] }, 'NavBarAdmin' )

View file

@ -1,7 +1,7 @@
class FFlt35
constructor: ->
data = App.Browser.detection()
if data.browser is 'Firefox' && data.version && data.version < 35
if data.browser.name is 'Firefox' && data.browser.major && data.browser.major < 35
# for firefox lower 35 we need to set a class to hide own dropdown images
# whole file can be removed after dropping firefox 34 and lower support

View file

@ -45,16 +45,15 @@ class _Singleton
# detect color support
@colorSupport = false
data = App.Browser.detection()
if data
if data.browser is 'Chrome'
if data.browser
if data.browser.name is 'Chrome'
@colorSupport = true
else if data.browser is 'Firefox'
if data.version >= 31.0
else if data.browser.anem is 'Firefox'
if data.browser.major >= 31.0
@colorSupport = true
else if data.browser is 'Safari'
else if data.browser.name is 'Safari'
@colorSupport = true
configReady: ->
for type, value of @currentConfig
if type is 'module' || type is 'content'

View file

@ -17,12 +17,14 @@ get used browser
}
###
class App.Browser
@detection: ->
parser = new UAParser()
data =
browser: @searchString(@dataBrowser) or "An unknown browser"
version: @searchVersion(navigator.userAgent) or @searchVersion(navigator.appVersion) or "an unknown version"
os: @searchString(@dataOS) or "an unknown os"
browser: parser.getBrowser()
device: parser.getDevice()
os: parser.getOS()
@check: ->
data = @detection()
@ -36,114 +38,21 @@ class App.Browser
Opera: 22
# disable id older
if data.browser && data.version
if map[data.browser] && data.version < map[data.browser]
@message(data, data.browser, map[data.browser])
if data.browser
if map[data.browser.name] && data.browser.major < map[data.browser.name]
@message(data, map[data.browser.name])
console.log('Browser not supported')
return false
# allow browser
return true
true
@message: (data, browser, version) ->
@message: (data, version) ->
new App.ControllerModal(
head: 'Browser too old!'
message: "Your Browser is not supported (#{data.browser} #{data.version} #{data.OS}). Please use a newer one (e. g. #{browser} #{version} or higher)."
message: "Your Browser is not supported (#{data.browser.name} #{data.browser.major} on #{data.os.name}). Please use a newer one (e. g. #{data.browser.name} #{version} or higher)."
close: false
backdrop: false
keyboard: false
shown: true
)
@searchString: (data) ->
i = 0
while i < data.length
dataString = data[i].string
dataProp = data[i].prop
@versionSearchString = data[i].versionSearch or data[i].identity
if dataString
return data[i].identity unless dataString.indexOf(data[i].subString) is -1
else return data[i].identity if dataProp
i++
@searchVersion: (dataString) ->
index = dataString.indexOf(@versionSearchString)
return if index is -1
parseFloat dataString.substring(index + @versionSearchString.length + 1)
@dataBrowser: [
string: navigator.userAgent
subString: "Chrome"
identity: "Chrome"
,
string: navigator.userAgent
subString: "OmniWeb"
versionSearch: "OmniWeb/"
identity: "OmniWeb"
,
string: navigator.vendor
subString: "Apple"
identity: "Safari"
versionSearch: "Version"
,
prop: window.opera
identity: "Opera"
versionSearch: "Version"
,
string: navigator.vendor
subString: "iCab"
identity: "iCab"
,
string: navigator.vendor
subString: "KDE"
identity: "Konqueror"
,
string: navigator.userAgent
subString: "Firefox"
identity: "Firefox"
,
string: navigator.vendor
subString: "Camino"
identity: "Camino"
,
# for newer Netscapes (6+)
string: navigator.userAgent
subString: "Netscape"
identity: "Netscape"
,
string: navigator.userAgent
subString: "MSIE"
identity: "Explorer"
versionSearch: "MSIE"
,
string: navigator.userAgent
subString: "Gecko"
identity: "Mozilla"
versionSearch: "rv"
,
# for older Netscapes (4-)
string: navigator.userAgent
subString: "Mozilla"
identity: "Netscape"
versionSearch: "Mozilla"
]
@dataOS: [
string: navigator.platform
subString: "Win"
identity: "Windows"
,
string: navigator.platform
subString: "Mac"
identity: "Mac"
,
string: navigator.userAgent
subString: "iPhone"
identity: "iPhone/iPod"
,
string: navigator.platform
subString: "Linux"
identity: "Linux"
]

View file

@ -0,0 +1,868 @@
/**
* UAParser.js v0.7.8
* Lightweight JavaScript-based User-Agent string parser
* https://github.com/faisalman/ua-parser-js
*
* Copyright © 2012-2015 Faisal Salman <fyzlman@gmail.com>
* Dual licensed under GPLv2 & MIT
*/
(function (window, undefined) {
'use strict';
//////////////
// Constants
/////////////
var LIBVERSION = '0.7.8',
EMPTY = '',
UNKNOWN = '?',
FUNC_TYPE = 'function',
UNDEF_TYPE = 'undefined',
OBJ_TYPE = 'object',
STR_TYPE = 'string',
MAJOR = 'major', // deprecated
MODEL = 'model',
NAME = 'name',
TYPE = 'type',
VENDOR = 'vendor',
VERSION = 'version',
ARCHITECTURE= 'architecture',
CONSOLE = 'console',
MOBILE = 'mobile',
TABLET = 'tablet',
SMARTTV = 'smarttv',
WEARABLE = 'wearable',
EMBEDDED = 'embedded';
///////////
// Helper
//////////
var util = {
extend : function (regexes, extensions) {
for (var i in extensions) {
if ("browser cpu device engine os".indexOf(i) !== -1 && extensions[i].length % 2 === 0) {
regexes[i] = extensions[i].concat(regexes[i]);
}
}
return regexes;
},
has : function (str1, str2) {
if (typeof str1 === "string") {
return str2.toLowerCase().indexOf(str1.toLowerCase()) !== -1;
} else {
return false;
}
},
lowerize : function (str) {
return str.toLowerCase();
},
major : function (version) {
return typeof(version) === STR_TYPE ? version.split(".")[0] : undefined;
}
};
///////////////
// Map helper
//////////////
var mapper = {
rgx : function () {
var result, i = 0, j, k, p, q, matches, match, args = arguments;
// loop through all regexes maps
while (i < args.length && !matches) {
var regex = args[i], // even sequence (0,2,4,..)
props = args[i + 1]; // odd sequence (1,3,5,..)
// construct object barebones
if (typeof result === UNDEF_TYPE) {
result = {};
for (p in props) {
q = props[p];
if (typeof q === OBJ_TYPE) {
result[q[0]] = undefined;
} else {
result[q] = undefined;
}
}
}
// try matching uastring with regexes
j = k = 0;
while (j < regex.length && !matches) {
matches = regex[j++].exec(this.getUA());
if (!!matches) {
for (p = 0; p < props.length; p++) {
match = matches[++k];
q = props[p];
// check if given property is actually array
if (typeof q === OBJ_TYPE && q.length > 0) {
if (q.length == 2) {
if (typeof q[1] == FUNC_TYPE) {
// assign modified match
result[q[0]] = q[1].call(this, match);
} else {
// assign given value, ignore regex match
result[q[0]] = q[1];
}
} else if (q.length == 3) {
// check whether function or regex
if (typeof q[1] === FUNC_TYPE && !(q[1].exec && q[1].test)) {
// call function (usually string mapper)
result[q[0]] = match ? q[1].call(this, match, q[2]) : undefined;
} else {
// sanitize match using given regex
result[q[0]] = match ? match.replace(q[1], q[2]) : undefined;
}
} else if (q.length == 4) {
result[q[0]] = match ? q[3].call(this, match.replace(q[1], q[2])) : undefined;
}
} else {
result[q] = match ? match : undefined;
}
}
}
}
i += 2;
}
return result;
},
str : function (str, map) {
for (var i in map) {
// check if array
if (typeof map[i] === OBJ_TYPE && map[i].length > 0) {
for (var j = 0; j < map[i].length; j++) {
if (util.has(map[i][j], str)) {
return (i === UNKNOWN) ? undefined : i;
}
}
} else if (util.has(map[i], str)) {
return (i === UNKNOWN) ? undefined : i;
}
}
return str;
}
};
///////////////
// String map
//////////////
var maps = {
browser : {
oldsafari : {
version : {
'1.0' : '/8',
'1.2' : '/1',
'1.3' : '/3',
'2.0' : '/412',
'2.0.2' : '/416',
'2.0.3' : '/417',
'2.0.4' : '/419',
'?' : '/'
}
}
},
device : {
amazon : {
model : {
'Fire Phone' : ['SD', 'KF']
}
},
sprint : {
model : {
'Evo Shift 4G' : '7373KT'
},
vendor : {
'HTC' : 'APA',
'Sprint' : 'Sprint'
}
}
},
os : {
windows : {
version : {
'ME' : '4.90',
'NT 3.11' : 'NT3.51',
'NT 4.0' : 'NT4.0',
'2000' : 'NT 5.0',
'XP' : ['NT 5.1', 'NT 5.2'],
'Vista' : 'NT 6.0',
'7' : 'NT 6.1',
'8' : 'NT 6.2',
'8.1' : 'NT 6.3',
'10' : ['NT 6.4', 'NT 10.0'],
'RT' : 'ARM'
}
}
}
};
//////////////
// Regex map
/////////////
var regexes = {
browser : [[
// Presto based
/(opera\smini)\/([\w\.-]+)/i, // Opera Mini
/(opera\s[mobiletab]+).+version\/([\w\.-]+)/i, // Opera Mobi/Tablet
/(opera).+version\/([\w\.]+)/i, // Opera > 9.80
/(opera)[\/\s]+([\w\.]+)/i // Opera < 9.80
], [NAME, VERSION], [
/\s(opr)\/([\w\.]+)/i // Opera Webkit
], [[NAME, 'Opera'], VERSION], [
// Mixed
/(kindle)\/([\w\.]+)/i, // Kindle
/(lunascape|maxthon|netfront|jasmine|blazer)[\/\s]?([\w\.]+)*/i,
// Lunascape/Maxthon/Netfront/Jasmine/Blazer
// Trident based
/(avant\s|iemobile|slim|baidu)(?:browser)?[\/\s]?([\w\.]*)/i,
// Avant/IEMobile/SlimBrowser/Baidu
/(?:ms|\()(ie)\s([\w\.]+)/i, // Internet Explorer
// Webkit/KHTML based
/(rekonq)\/([\w\.]+)*/i, // Rekonq
/(chromium|flock|rockmelt|midori|epiphany|silk|skyfire|ovibrowser|bolt|iron|vivaldi)\/([\w\.-]+)/i
// Chromium/Flock/RockMelt/Midori/Epiphany/Silk/Skyfire/Bolt/Iron
], [NAME, VERSION], [
/(trident).+rv[:\s]([\w\.]+).+like\sgecko/i // IE11
], [[NAME, 'IE'], VERSION], [
/(edge)\/((\d+)?[\w\.]+)/i // Microsoft Edge
], [NAME, VERSION], [
/(yabrowser)\/([\w\.]+)/i // Yandex
], [[NAME, 'Yandex'], VERSION], [
/(comodo_dragon)\/([\w\.]+)/i // Comodo Dragon
], [[NAME, /_/g, ' '], VERSION], [
/(chrome|omniweb|arora|[tizenoka]{5}\s?browser)\/v?([\w\.]+)/i,
// Chrome/OmniWeb/Arora/Tizen/Nokia
/(uc\s?browser|qqbrowser)[\/\s]?([\w\.]+)/i
// UCBrowser/QQBrowser
], [NAME, VERSION], [
/(dolfin)\/([\w\.]+)/i // Dolphin
], [[NAME, 'Dolphin'], VERSION], [
/((?:android.+)crmo|crios)\/([\w\.]+)/i // Chrome for Android/iOS
], [[NAME, 'Chrome'], VERSION], [
/XiaoMi\/MiuiBrowser\/([\w\.]+)/i // MIUI Browser
], [VERSION, [NAME, 'MIUI Browser']], [
/android.+version\/([\w\.]+)\s+(?:mobile\s?safari|safari)/i // Android Browser
], [VERSION, [NAME, 'Android Browser']], [
/FBAV\/([\w\.]+);/i // Facebook App for iOS
], [VERSION, [NAME, 'Facebook']], [
/version\/([\w\.]+).+?mobile\/\w+\s(safari)/i // Mobile Safari
], [VERSION, [NAME, 'Mobile Safari']], [
/version\/([\w\.]+).+?(mobile\s?safari|safari)/i // Safari & Safari Mobile
], [VERSION, NAME], [
/webkit.+?(mobile\s?safari|safari)(\/[\w\.]+)/i // Safari < 3.0
], [NAME, [VERSION, mapper.str, maps.browser.oldsafari.version]], [
/(konqueror)\/([\w\.]+)/i, // Konqueror
/(webkit|khtml)\/([\w\.]+)/i
], [NAME, VERSION], [
// Gecko based
/(navigator|netscape)\/([\w\.-]+)/i // Netscape
], [[NAME, 'Netscape'], VERSION], [
/(swiftfox)/i, // Swiftfox
/(icedragon|iceweasel|camino|chimera|fennec|maemo\sbrowser|minimo|conkeror)[\/\s]?([\w\.\+]+)/i,
// IceDragon/Iceweasel/Camino/Chimera/Fennec/Maemo/Minimo/Conkeror
/(firefox|seamonkey|k-meleon|icecat|iceape|firebird|phoenix)\/([\w\.-]+)/i,
// Firefox/SeaMonkey/K-Meleon/IceCat/IceApe/Firebird/Phoenix
/(mozilla)\/([\w\.]+).+rv\:.+gecko\/\d+/i, // Mozilla
// Other
/(polaris|lynx|dillo|icab|doris|amaya|w3m|netsurf)[\/\s]?([\w\.]+)/i,
// Polaris/Lynx/Dillo/iCab/Doris/Amaya/w3m/NetSurf
/(links)\s\(([\w\.]+)/i, // Links
/(gobrowser)\/?([\w\.]+)*/i, // GoBrowser
/(ice\s?browser)\/v?([\w\._]+)/i, // ICE Browser
/(mosaic)[\/\s]([\w\.]+)/i // Mosaic
], [NAME, VERSION]
/* /////////////////////
// Media players BEGIN
////////////////////////
, [
/(apple(?:coremedia|))\/((\d+)[\w\._]+)/i, // Generic Apple CoreMedia
/(coremedia) v((\d+)[\w\._]+)/i
], [NAME, VERSION], [
/(aqualung|lyssna|bsplayer)\/((\d+)?[\w\.-]+)/i // Aqualung/Lyssna/BSPlayer
], [NAME, VERSION], [
/(ares|ossproxy)\s((\d+)[\w\.-]+)/i // Ares/OSSProxy
], [NAME, VERSION], [
/(audacious|audimusicstream|amarok|bass|core|dalvik|gnomemplayer|music on console|nsplayer|psp-internetradioplayer|videos)\/((\d+)[\w\.-]+)/i,
// Audacious/AudiMusicStream/Amarok/BASS/OpenCORE/Dalvik/GnomeMplayer/MoC
// NSPlayer/PSP-InternetRadioPlayer/Videos
/(clementine|music player daemon)\s((\d+)[\w\.-]+)/i, // Clementine/MPD
/(lg player|nexplayer)\s((\d+)[\d\.]+)/i,
/player\/(nexplayer|lg player)\s((\d+)[\w\.-]+)/i // NexPlayer/LG Player
], [NAME, VERSION], [
/(nexplayer)\s((\d+)[\w\.-]+)/i // Nexplayer
], [NAME, VERSION], [
/(flrp)\/((\d+)[\w\.-]+)/i // Flip Player
], [[NAME, 'Flip Player'], VERSION], [
/(fstream|nativehost|queryseekspider|ia-archiver|facebookexternalhit)/i
// FStream/NativeHost/QuerySeekSpider/IA Archiver/facebookexternalhit
], [NAME], [
/(gstreamer) souphttpsrc (?:\([^\)]+\)){0,1} libsoup\/((\d+)[\w\.-]+)/i
// Gstreamer
], [NAME, VERSION], [
/(htc streaming player)\s[\w_]+\s\/\s((\d+)[\d\.]+)/i, // HTC Streaming Player
/(java|python-urllib|python-requests|wget|libcurl)\/((\d+)[\w\.-_]+)/i,
// Java/urllib/requests/wget/cURL
/(lavf)((\d+)[\d\.]+)/i // Lavf (FFMPEG)
], [NAME, VERSION], [
/(htc_one_s)\/((\d+)[\d\.]+)/i // HTC One S
], [[NAME, /_/g, ' '], VERSION], [
/(mplayer)(?:\s|\/)(?:(?:sherpya-){0,1}svn)(?:-|\s)(r\d+(?:-\d+[\w\.-]+){0,1})/i
// MPlayer SVN
], [NAME, VERSION], [
/(mplayer)(?:\s|\/|[unkow-]+)((\d+)[\w\.-]+)/i // MPlayer
], [NAME, VERSION], [
/(mplayer)/i, // MPlayer (no other info)
/(yourmuze)/i, // YourMuze
/(media player classic|nero showtime)/i // Media Player Classic/Nero ShowTime
], [NAME], [
/(nero (?:home|scout))\/((\d+)[\w\.-]+)/i // Nero Home/Nero Scout
], [NAME, VERSION], [
/(nokia\d+)\/((\d+)[\w\.-]+)/i // Nokia
], [NAME, VERSION], [
/\s(songbird)\/((\d+)[\w\.-]+)/i // Songbird/Philips-Songbird
], [NAME, VERSION], [
/(winamp)3 version ((\d+)[\w\.-]+)/i, // Winamp
/(winamp)\s((\d+)[\w\.-]+)/i,
/(winamp)mpeg\/((\d+)[\w\.-]+)/i
], [NAME, VERSION], [
/(ocms-bot|tapinradio|tunein radio|unknown|winamp|inlight radio)/i // OCMS-bot/tap in radio/tunein/unknown/winamp (no other info)
// inlight radio
], [NAME], [
/(quicktime|rma|radioapp|radioclientapplication|soundtap|totem|stagefright|streamium)\/((\d+)[\w\.-]+)/i
// QuickTime/RealMedia/RadioApp/RadioClientApplication/
// SoundTap/Totem/Stagefright/Streamium
], [NAME, VERSION], [
/(smp)((\d+)[\d\.]+)/i // SMP
], [NAME, VERSION], [
/(vlc) media player - version ((\d+)[\w\.]+)/i, // VLC Videolan
/(vlc)\/((\d+)[\w\.-]+)/i,
/(xbmc|gvfs|xine|xmms|irapp)\/((\d+)[\w\.-]+)/i, // XBMC/gvfs/Xine/XMMS/irapp
/(foobar2000)\/((\d+)[\d\.]+)/i, // Foobar2000
/(itunes)\/((\d+)[\d\.]+)/i // iTunes
], [NAME, VERSION], [
/(wmplayer)\/((\d+)[\w\.-]+)/i, // Windows Media Player
/(windows-media-player)\/((\d+)[\w\.-]+)/i
], [[NAME, /-/g, ' '], VERSION], [
/windows\/((\d+)[\w\.-]+) upnp\/[\d\.]+ dlnadoc\/[\d\.]+ (home media server)/i
// Windows Media Server
], [VERSION, [NAME, 'Windows']], [
/(com\.riseupradioalarm)\/((\d+)[\d\.]*)/i // RiseUP Radio Alarm
], [NAME, VERSION], [
/(rad.io)\s((\d+)[\d\.]+)/i, // Rad.io
/(radio.(?:de|at|fr))\s((\d+)[\d\.]+)/i
], [[NAME, 'rad.io'], VERSION]
//////////////////////
// Media players END
////////////////////*/
],
cpu : [[
/(?:(amd|x(?:(?:86|64)[_-])?|wow|win)64)[;\)]/i // AMD64
], [[ARCHITECTURE, 'amd64']], [
/(ia32(?=;))/i // IA32 (quicktime)
], [[ARCHITECTURE, util.lowerize]], [
/((?:i[346]|x)86)[;\)]/i // IA32
], [[ARCHITECTURE, 'ia32']], [
// PocketPC mistakenly identified as PowerPC
/windows\s(ce|mobile);\sppc;/i
], [[ARCHITECTURE, 'arm']], [
/((?:ppc|powerpc)(?:64)?)(?:\smac|;|\))/i // PowerPC
], [[ARCHITECTURE, /ower/, '', util.lowerize]], [
/(sun4\w)[;\)]/i // SPARC
], [[ARCHITECTURE, 'sparc']], [
/((?:avr32|ia64(?=;))|68k(?=\))|arm(?:64|(?=v\d+;))|(?=atmel\s)avr|(?:irix|mips|sparc)(?:64)?(?=;)|pa-risc)/i
// IA64, 68K, ARM/64, AVR/32, IRIX/64, MIPS/64, SPARC/64, PA-RISC
], [[ARCHITECTURE, util.lowerize]]
],
device : [[
/\((ipad|playbook);[\w\s\);-]+(rim|apple)/i // iPad/PlayBook
], [MODEL, VENDOR, [TYPE, TABLET]], [
/applecoremedia\/[\w\.]+ \((ipad)/ // iPad
], [MODEL, [VENDOR, 'Apple'], [TYPE, TABLET]], [
/(apple\s{0,1}tv)/i // Apple TV
], [[MODEL, 'Apple TV'], [VENDOR, 'Apple']], [
/(archos)\s(gamepad2?)/i, // Archos
/(hp).+(touchpad)/i, // HP TouchPad
/(kindle)\/([\w\.]+)/i, // Kindle
/\s(nook)[\w\s]+build\/(\w+)/i, // Nook
/(dell)\s(strea[kpr\s\d]*[\dko])/i // Dell Streak
], [VENDOR, MODEL, [TYPE, TABLET]], [
/(kf[A-z]+)\sbuild\/[\w\.]+.*silk\//i // Kindle Fire HD
], [MODEL, [VENDOR, 'Amazon'], [TYPE, TABLET]], [
/(sd|kf)[0349hijorstuw]+\sbuild\/[\w\.]+.*silk\//i // Fire Phone
], [[MODEL, mapper.str, maps.device.amazon.model], [VENDOR, 'Amazon'], [TYPE, MOBILE]], [
/\((ip[honed|\s\w*]+);.+(apple)/i // iPod/iPhone
], [MODEL, VENDOR, [TYPE, MOBILE]], [
/\((ip[honed|\s\w*]+);/i // iPod/iPhone
], [MODEL, [VENDOR, 'Apple'], [TYPE, MOBILE]], [
/(blackberry)[\s-]?(\w+)/i, // BlackBerry
/(blackberry|benq|palm(?=\-)|sonyericsson|acer|asus|dell|huawei|meizu|motorola|polytron)[\s_-]?([\w-]+)*/i,
// BenQ/Palm/Sony-Ericsson/Acer/Asus/Dell/Huawei/Meizu/Motorola/Polytron
/(hp)\s([\w\s]+\w)/i, // HP iPAQ
/(asus)-?(\w+)/i // Asus
], [VENDOR, MODEL, [TYPE, MOBILE]], [
/\(bb10;\s(\w+)/i // BlackBerry 10
], [MODEL, [VENDOR, 'BlackBerry'], [TYPE, MOBILE]], [
// Asus Tablets
/android.+(transfo[prime\s]{4,10}\s\w+|eeepc|slider\s\w+|nexus 7)/i
], [MODEL, [VENDOR, 'Asus'], [TYPE, TABLET]], [
/(sony)\s(tablet\s[ps])\sbuild\//i, // Sony
/(sony)?(?:sgp.+)\sbuild\//i
], [[VENDOR, 'Sony'], [MODEL, 'Xperia Tablet'], [TYPE, TABLET]], [
/(?:sony)?(?:(?:(?:c|d)\d{4})|(?:so[-l].+))\sbuild\//i
], [[VENDOR, 'Sony'], [MODEL, 'Xperia Phone'], [TYPE, MOBILE]], [
/\s(ouya)\s/i, // Ouya
/(nintendo)\s([wids3u]+)/i // Nintendo
], [VENDOR, MODEL, [TYPE, CONSOLE]], [
/android.+;\s(shield)\sbuild/i // Nvidia
], [MODEL, [VENDOR, 'Nvidia'], [TYPE, CONSOLE]], [
/(playstation\s[3portablevi]+)/i // Playstation
], [MODEL, [VENDOR, 'Sony'], [TYPE, CONSOLE]], [
/(sprint\s(\w+))/i // Sprint Phones
], [[VENDOR, mapper.str, maps.device.sprint.vendor], [MODEL, mapper.str, maps.device.sprint.model], [TYPE, MOBILE]], [
/(lenovo)\s?(S(?:5000|6000)+(?:[-][\w+]))/i // Lenovo tablets
], [VENDOR, MODEL, [TYPE, TABLET]], [
/(htc)[;_\s-]+([\w\s]+(?=\))|\w+)*/i, // HTC
/(zte)-(\w+)*/i, // ZTE
/(alcatel|geeksphone|huawei|lenovo|nexian|panasonic|(?=;\s)sony)[_\s-]?([\w-]+)*/i
// Alcatel/GeeksPhone/Huawei/Lenovo/Nexian/Panasonic/Sony
], [VENDOR, [MODEL, /_/g, ' '], [TYPE, MOBILE]], [
/(nexus\s9)/i // HTC Nexus 9
], [MODEL, [VENDOR, 'HTC'], [TYPE, TABLET]], [
/[\s\(;](xbox(?:\sone)?)[\s\);]/i // Microsoft Xbox
], [MODEL, [VENDOR, 'Microsoft'], [TYPE, CONSOLE]], [
/(kin\.[onetw]{3})/i // Microsoft Kin
], [[MODEL, /\./g, ' '], [VENDOR, 'Microsoft'], [TYPE, MOBILE]], [
// Motorola
/\s(milestone|droid(?:[2-4x]|\s(?:bionic|x2|pro|razr))?(:?\s4g)?)[\w\s]+build\//i,
/mot[\s-]?(\w+)*/i,
/(XT\d{3,4}) build\//i
], [MODEL, [VENDOR, 'Motorola'], [TYPE, MOBILE]], [
/android.+\s(mz60\d|xoom[\s2]{0,2})\sbuild\//i
], [MODEL, [VENDOR, 'Motorola'], [TYPE, TABLET]], [
/android.+((sch-i[89]0\d|shw-m380s|gt-p\d{4}|gt-n8000|sgh-t8[56]9|nexus 10))/i,
/((SM-T\w+))/i
], [[VENDOR, 'Samsung'], MODEL, [TYPE, TABLET]], [ // Samsung
/((s[cgp]h-\w+|gt-\w+|galaxy\snexus|sm-n900))/i,
/(sam[sung]*)[\s-]*(\w+-?[\w-]*)*/i,
/sec-((sgh\w+))/i
], [[VENDOR, 'Samsung'], MODEL, [TYPE, MOBILE]], [
/(samsung);smarttv/i
], [VENDOR, MODEL, [TYPE, SMARTTV]], [
/\(dtv[\);].+(aquos)/i // Sharp
], [MODEL, [VENDOR, 'Sharp'], [TYPE, SMARTTV]], [
/sie-(\w+)*/i // Siemens
], [MODEL, [VENDOR, 'Siemens'], [TYPE, MOBILE]], [
/(maemo|nokia).*(n900|lumia\s\d+)/i, // Nokia
/(nokia)[\s_-]?([\w-]+)*/i
], [[VENDOR, 'Nokia'], MODEL, [TYPE, MOBILE]], [
/android\s3\.[\s\w;-]{10}(a\d{3})/i // Acer
], [MODEL, [VENDOR, 'Acer'], [TYPE, TABLET]], [
/android\s3\.[\s\w;-]{10}(lg?)-([06cv9]{3,4})/i // LG Tablet
], [[VENDOR, 'LG'], MODEL, [TYPE, TABLET]], [
/(lg) netcast\.tv/i // LG SmartTV
], [VENDOR, MODEL, [TYPE, SMARTTV]], [
/(nexus\s[45])/i, // LG
/lg[e;\s\/-]+(\w+)*/i
], [MODEL, [VENDOR, 'LG'], [TYPE, MOBILE]], [
/android.+(ideatab[a-z0-9\-\s]+)/i // Lenovo
], [MODEL, [VENDOR, 'Lenovo'], [TYPE, TABLET]], [
/linux;.+((jolla));/i // Jolla
], [VENDOR, MODEL, [TYPE, MOBILE]], [
/((pebble))app\/[\d\.]+\s/i // Pebble
], [VENDOR, MODEL, [TYPE, WEARABLE]], [
/android.+;\s(glass)\s\d/i // Google Glass
], [MODEL, [VENDOR, 'Google'], [TYPE, WEARABLE]], [
/android.+(\w+)\s+build\/hm\1/i, // Xiaomi Hongmi 'numeric' models
/android.+(hm[\s\-_]*note?[\s_]*(?:\d\w)?)\s+build/i, // Xiaomi Hongmi
/android.+(mi[\s\-_]*(?:one|one[\s_]plus)?[\s_]*(?:\d\w)?)\s+build/i // Xiaomi Mi
], [[MODEL, /_/g, ' '], [VENDOR, 'Xiaomi'], [TYPE, MOBILE]], [
/(mobile|tablet);.+rv\:.+gecko\//i // Unidentifiable
], [[TYPE, util.lowerize], VENDOR, MODEL]
/*//////////////////////////
// TODO: move to string map
////////////////////////////
/(C6603)/i // Sony Xperia Z C6603
], [[MODEL, 'Xperia Z C6603'], [VENDOR, 'Sony'], [TYPE, MOBILE]], [
/(C6903)/i // Sony Xperia Z 1
], [[MODEL, 'Xperia Z 1'], [VENDOR, 'Sony'], [TYPE, MOBILE]], [
/(SM-G900[F|H])/i // Samsung Galaxy S5
], [[MODEL, 'Galaxy S5'], [VENDOR, 'Samsung'], [TYPE, MOBILE]], [
/(SM-G7102)/i // Samsung Galaxy Grand 2
], [[MODEL, 'Galaxy Grand 2'], [VENDOR, 'Samsung'], [TYPE, MOBILE]], [
/(SM-G530H)/i // Samsung Galaxy Grand Prime
], [[MODEL, 'Galaxy Grand Prime'], [VENDOR, 'Samsung'], [TYPE, MOBILE]], [
/(SM-G313HZ)/i // Samsung Galaxy V
], [[MODEL, 'Galaxy V'], [VENDOR, 'Samsung'], [TYPE, MOBILE]], [
/(SM-T805)/i // Samsung Galaxy Tab S 10.5
], [[MODEL, 'Galaxy Tab S 10.5'], [VENDOR, 'Samsung'], [TYPE, TABLET]], [
/(SM-G800F)/i // Samsung Galaxy S5 Mini
], [[MODEL, 'Galaxy S5 Mini'], [VENDOR, 'Samsung'], [TYPE, MOBILE]], [
/(SM-T311)/i // Samsung Galaxy Tab 3 8.0
], [[MODEL, 'Galaxy Tab 3 8.0'], [VENDOR, 'Samsung'], [TYPE, TABLET]], [
/(R1001)/i // Oppo R1001
], [MODEL, [VENDOR, 'OPPO'], [TYPE, MOBILE]], [
/(X9006)/i // Oppo Find 7a
], [[MODEL, 'Find 7a'], [VENDOR, 'Oppo'], [TYPE, MOBILE]], [
/(R2001)/i // Oppo YOYO R2001
], [[MODEL, 'Yoyo R2001'], [VENDOR, 'Oppo'], [TYPE, MOBILE]], [
/(R815)/i // Oppo Clover R815
], [[MODEL, 'Clover R815'], [VENDOR, 'Oppo'], [TYPE, MOBILE]], [
/(U707)/i // Oppo Find Way S
], [[MODEL, 'Find Way S'], [VENDOR, 'Oppo'], [TYPE, MOBILE]], [
/(T3C)/i // Advan Vandroid T3C
], [MODEL, [VENDOR, 'Advan'], [TYPE, TABLET]], [
/(ADVAN T1J\+)/i // Advan Vandroid T1J+
], [[MODEL, 'Vandroid T1J+'], [VENDOR, 'Advan'], [TYPE, TABLET]], [
/(ADVAN S4A)/i // Advan Vandroid S4A
], [[MODEL, 'Vandroid S4A'], [VENDOR, 'Advan'], [TYPE, MOBILE]], [
/(V972M)/i // ZTE V972M
], [MODEL, [VENDOR, 'ZTE'], [TYPE, MOBILE]], [
/(i-mobile)\s(IQ\s[\d\.]+)/i // i-mobile IQ
], [VENDOR, MODEL, [TYPE, MOBILE]], [
/(IQ6.3)/i // i-mobile IQ IQ 6.3
], [[MODEL, 'IQ 6.3'], [VENDOR, 'i-mobile'], [TYPE, MOBILE]], [
/(i-mobile)\s(i-style\s[\d\.]+)/i // i-mobile i-STYLE
], [VENDOR, MODEL, [TYPE, MOBILE]], [
/(i-STYLE2.1)/i // i-mobile i-STYLE 2.1
], [[MODEL, 'i-STYLE 2.1'], [VENDOR, 'i-mobile'], [TYPE, MOBILE]], [
/(mobiistar touch LAI 512)/i // mobiistar touch LAI 512
], [[MODEL, 'Touch LAI 512'], [VENDOR, 'mobiistar'], [TYPE, MOBILE]], [
/////////////
// END TODO
///////////*/
],
engine : [[
/windows.+\sedge\/([\w\.]+)/i // EdgeHTML
], [VERSION, [NAME, 'EdgeHTML']], [
/(presto)\/([\w\.]+)/i, // Presto
/(webkit|trident|netfront|netsurf|amaya|lynx|w3m)\/([\w\.]+)/i, // WebKit/Trident/NetFront/NetSurf/Amaya/Lynx/w3m
/(khtml|tasman|links)[\/\s]\(?([\w\.]+)/i, // KHTML/Tasman/Links
/(icab)[\/\s]([23]\.[\d\.]+)/i // iCab
], [NAME, VERSION], [
/rv\:([\w\.]+).*(gecko)/i // Gecko
], [VERSION, NAME]
],
os : [[
// Windows based
/microsoft\s(windows)\s(vista|xp)/i // Windows (iTunes)
], [NAME, VERSION], [
/(windows)\snt\s6\.2;\s(arm)/i, // Windows RT
/(windows\sphone(?:\sos)*|windows\smobile|windows)[\s\/]?([ntce\d\.\s]+\w)/i
], [NAME, [VERSION, mapper.str, maps.os.windows.version]], [
/(win(?=3|9|n)|win\s9x\s)([nt\d\.]+)/i
], [[NAME, 'Windows'], [VERSION, mapper.str, maps.os.windows.version]], [
// Mobile/Embedded OS
/\((bb)(10);/i // BlackBerry 10
], [[NAME, 'BlackBerry'], VERSION], [
/(blackberry)\w*\/?([\w\.]+)*/i, // Blackberry
/(tizen)[\/\s]([\w\.]+)/i, // Tizen
/(android|webos|palm\sos|qnx|bada|rim\stablet\sos|meego|contiki)[\/\s-]?([\w\.]+)*/i,
// Android/WebOS/Palm/QNX/Bada/RIM/MeeGo/Contiki
/linux;.+(sailfish);/i // Sailfish OS
], [NAME, VERSION], [
/(symbian\s?os|symbos|s60(?=;))[\/\s-]?([\w\.]+)*/i // Symbian
], [[NAME, 'Symbian'], VERSION], [
/\((series40);/i // Series 40
], [NAME], [
/mozilla.+\(mobile;.+gecko.+firefox/i // Firefox OS
], [[NAME, 'Firefox OS'], VERSION], [
// Console
/(nintendo|playstation)\s([wids3portablevu]+)/i, // Nintendo/Playstation
// GNU/Linux based
/(mint)[\/\s\(]?(\w+)*/i, // Mint
/(mageia|vectorlinux)[;\s]/i, // Mageia/VectorLinux
/(joli|[kxln]?ubuntu|debian|[open]*suse|gentoo|arch|slackware|fedora|mandriva|centos|pclinuxos|redhat|zenwalk|linpus)[\/\s-]?([\w\.-]+)*/i,
// Joli/Ubuntu/Debian/SUSE/Gentoo/Arch/Slackware
// Fedora/Mandriva/CentOS/PCLinuxOS/RedHat/Zenwalk/Linpus
/(hurd|linux)\s?([\w\.]+)*/i, // Hurd/Linux
/(gnu)\s?([\w\.]+)*/i // GNU
], [NAME, VERSION], [
/(cros)\s[\w]+\s([\w\.]+\w)/i // Chromium OS
], [[NAME, 'Chromium OS'], VERSION],[
// Solaris
/(sunos)\s?([\w\.]+\d)*/i // Solaris
], [[NAME, 'Solaris'], VERSION], [
// BSD based
/\s([frentopc-]{0,4}bsd|dragonfly)\s?([\w\.]+)*/i // FreeBSD/NetBSD/OpenBSD/PC-BSD/DragonFly
], [NAME, VERSION],[
/(ip[honead]+)(?:.*os\s*([\w]+)*\slike\smac|;\sopera)/i // iOS
], [[NAME, 'iOS'], [VERSION, /_/g, '.']], [
/(mac\sos\sx)\s?([\w\s\.]+\w)*/i,
/(macintosh|mac(?=_powerpc)\s)/i // Mac OS
], [[NAME, 'Mac OS'], [VERSION, /_/g, '.']], [
// Other
/((?:open)?solaris)[\/\s-]?([\w\.]+)*/i, // Solaris
/(haiku)\s(\w+)/i, // Haiku
/(aix)\s((\d)(?=\.|\)|\s)[\w\.]*)*/i, // AIX
/(plan\s9|minix|beos|os\/2|amigaos|morphos|risc\sos|openvms)/i,
// Plan9/Minix/BeOS/OS2/AmigaOS/MorphOS/RISCOS/OpenVMS
/(unix)\s?([\w\.]+)*/i // UNIX
], [NAME, VERSION]
]
};
/////////////////
// Constructor
////////////////
var UAParser = function (uastring, extensions) {
if (!(this instanceof UAParser)) {
return new UAParser(uastring, extensions).getResult();
}
var ua = uastring || ((window && window.navigator && window.navigator.userAgent) ? window.navigator.userAgent : EMPTY);
var rgxmap = extensions ? util.extend(regexes, extensions) : regexes;
this.getBrowser = function () {
var browser = mapper.rgx.apply(this, rgxmap.browser);
browser.major = util.major(browser.version);
return browser;
};
this.getCPU = function () {
return mapper.rgx.apply(this, rgxmap.cpu);
};
this.getDevice = function () {
return mapper.rgx.apply(this, rgxmap.device);
};
this.getEngine = function () {
return mapper.rgx.apply(this, rgxmap.engine);
};
this.getOS = function () {
return mapper.rgx.apply(this, rgxmap.os);
};
this.getResult = function() {
return {
ua : this.getUA(),
browser : this.getBrowser(),
engine : this.getEngine(),
os : this.getOS(),
device : this.getDevice(),
cpu : this.getCPU()
};
};
this.getUA = function () {
return ua;
};
this.setUA = function (uastring) {
ua = uastring;
return this;
};
this.setUA(ua);
return this;
};
UAParser.VERSION = LIBVERSION;
UAParser.BROWSER = {
NAME : NAME,
MAJOR : MAJOR, // deprecated
VERSION : VERSION
};
UAParser.CPU = {
ARCHITECTURE : ARCHITECTURE
};
UAParser.DEVICE = {
MODEL : MODEL,
VENDOR : VENDOR,
TYPE : TYPE,
CONSOLE : CONSOLE,
MOBILE : MOBILE,
SMARTTV : SMARTTV,
TABLET : TABLET,
WEARABLE: WEARABLE,
EMBEDDED: EMBEDDED
};
UAParser.ENGINE = {
NAME : NAME,
VERSION : VERSION
};
UAParser.OS = {
NAME : NAME,
VERSION : VERSION
};
///////////
// Export
//////////
// check js environment
if (typeof(exports) !== UNDEF_TYPE) {
// nodejs env
if (typeof module !== UNDEF_TYPE && module.exports) {
exports = module.exports = UAParser;
}
exports.UAParser = UAParser;
} else {
// requirejs env (optional)
if (typeof(define) === FUNC_TYPE && define.amd) {
define(function () {
return UAParser;
});
} else {
// browser env
window.UAParser = UAParser;
}
}
// jQuery/Zepto specific (optional)
// Note:
// In AMD env the global scope should be kept clean, but jQuery is an exception.
// jQuery always exports to global scope, unless jQuery.noConflict(true) is used,
// and we should catch that.
var $ = window.jQuery || window.Zepto;
if (typeof $ !== UNDEF_TYPE) {
var parser = new UAParser();
$.ua = parser.getResult();
$.ua.get = function() {
return parser.getUA();
};
$.ua.set = function (uastring) {
parser.setUA(uastring);
var result = parser.getResult();
for (var prop in result) {
$.ua[prop] = result[prop];
}
};
}
})(typeof window === 'object' ? window : this);

View file

@ -3,7 +3,7 @@
<h1><%- @T( @header ) %> <small><%- @T( @subHeader ) %></small></h1>
</div>
</div>
<ul class="nav nav-tabs <% if @tabs.length <= 1: %>hide<% end %>" role="tablist">
<ul class="nav nav-tabs" role="tablist">
<% for tab in @tabs: %>
<li><a href="#<%= tab.target %>" role="tab" data-toggle="tab"><%- @T( tab.name ) %></a></li>
<% end %>

View file

@ -86,7 +86,7 @@ class ApplicationController < ActionController::Base
# check if remote ip need to be updated
if !session[:remote_id] || session[:remote_id] != request.remote_ip
session[:remote_id] = request.remote_ip
session[:geo] = GeoIp.location( request.remote_ip )
session[:geo] = Service::GeoIp.location( request.remote_ip )
end
# fill user agent

View file

@ -32,11 +32,13 @@ class SettingsController < ApplicationController
# PUT /settings/1
def update
return if deny_if_not_role(Z_ROLENAME_ADMIN)
return if !check_access
model_update_render(Setting, params)
end
# PUT /settings/image/:id
def update_image
return if deny_if_not_role(Z_ROLENAME_ADMIN)
if !params[:logo]
render json: {
@ -91,4 +93,16 @@ class SettingsController < ApplicationController
return if deny_if_not_role(Z_ROLENAME_ADMIN)
model_destory_render(Setting, params)
end
private
def check_access
return true if !Setting.get('system_online_service')
setting = Setting.find(params[:id])
return true if setting.preferences && !setting.preferences[:online_service_disable]
response_access_deny
false
end
end

View file

@ -22,6 +22,7 @@ class TranslationsController < ApplicationController
# POST /translations/sync
def sync
return if deny_if_not_role(Z_ROLENAME_ADMIN)
Locale.load
Translation.load
render json: { message: 'ok' }, status: :ok
end

View file

@ -130,7 +130,7 @@ class UsersController < ApplicationController
# fetch org logo
if user.email
Zammad::BigData::Organization.suggest_system_image(user.email)
Service::Image.organization_suggest(user.email)
end
end

View file

@ -146,7 +146,7 @@ add a avatar
end
# fetch image
image = Zammad::BigData::User.image(data[:url])
image = Service::Image.user(data[:url])
return if !image
if !data[:resize]
data[:resize] = {}

View file

@ -7,7 +7,7 @@ class Channel::Facebook
def fetch (channel)
@channel = channel
@facebook = Facebook.new( @channel[:options][:auth] )
@facebook = Facebook.new( @channel[:options] )
@sync = @channel[:options][:sync]
Rails.logger.debug 'facebook fetch started'
@ -22,7 +22,7 @@ class Channel::Facebook
def send(article, _notification = false)
@channel = Channel.find_by( area: 'Facebook::Inbound', active: true )
@facebook = Facebook.new( @channel[:options][:auth] )
@facebook = Facebook.new( @channel[:options] )
tweet = @facebook.from_article(article)
disconnect

View file

@ -13,6 +13,7 @@ class Locale < ApplicationModel
}
)
fail "Can't load locales from #{url}" if !result
fail "Can't load locales from #{url}: #{result.error}" if !result.success?
ActiveRecord::Base.transaction do

View file

@ -54,7 +54,7 @@ class Observer::User::Geo < ActiveRecord::Observer
return if address == ''
# lookup
latlng = GeoLocation.geocode( address )
latlng = Service::GeoLocation.geocode( address )
return if !latlng
# store data

View file

@ -30,7 +30,7 @@ module Ticket::Number::Increment
if config[:checksum]
min_digs = min_digs.to_i - 1
end
fillup = Setting.get('system_id') || '1'
fillup = Setting.get('system_id').to_s || '1'
( 1..100 ).each {
next if ( fillup.length.to_i + counter_increment.to_s.length.to_i ) >= min_digs.to_i

View file

@ -0,0 +1,76 @@
class UpdateServices < ActiveRecord::Migration
def up
Setting.create_or_update(
title: 'Image Service',
name: 'image_backend',
area: 'System::Services',
description: 'Defines the backend for user and organization image lookups.',
options: {
form: [
{
display: '',
null: true,
name: 'image_backend',
tag: 'select',
options: {
'' => '-',
'Service::Image::Zammad' => 'Zammad Image Service',
},
},
],
},
state: 'Service::Image::Zammad',
preferences: { prio: 1 },
frontend: false
)
Setting.create_or_update(
title: 'Geo IP Service',
name: 'geo_ip_backend',
area: 'System::Services',
description: 'Defines the backend for geo IP lookups. Show also location of an IP address if an IP address is shown.',
options: {
form: [
{
display: '',
null: true,
name: 'geo_ip_backend',
tag: 'select',
options: {
'' => '-',
'Service::GeoIp::Zammad' => 'Zammad GeoIP Service',
},
},
],
},
state: 'Service::GeoIp::Zammad',
preferences: { prio: 2 },
frontend: false
)
Setting.create_or_update(
title: 'Geo Location Service',
name: 'geo_location_backend',
area: 'System::Services',
description: 'Defines the backend for geo location lookups to store geo locations for addresses.',
options: {
form: [
{
display: '',
null: true,
name: 'geo_location_backend',
tag: 'select',
options: {
'' => '-',
'Service::GeoLocation::Gmaps' => 'Google Maps',
},
},
],
},
state: 'Service::GeoLocation::Gmaps',
preferences: { prio: 3 },
frontend: false
)
end
end

View file

@ -0,0 +1,69 @@
class UpdateSettings < ActiveRecord::Migration
def up
Setting.create_or_update(
title: 'Organization',
name: 'organization',
area: 'System::Branding',
description: 'Will be shown in the app and is included in email footers.',
options: {
form: [
{
display: '',
null: false,
name: 'organization',
tag: 'input',
},
],
},
state: '',
preferences: { prio: 2 },
frontend: true
)
Setting.create_or_update(
title: 'Send client stats',
name: 'ui_send_client_stats',
area: 'System::UI',
description: 'Send client stats/error message to central server to improve the usability.',
options: {
form: [
{
display: '',
null: true,
name: 'ui_send_client_stats',
tag: 'boolean',
options: {
true => 'yes',
false => 'no',
},
},
],
},
state: true,
preferences: { prio: 1 },
frontend: true
)
Setting.create_or_update(
title: 'Client storage',
name: 'ui_client_storage',
area: 'System::UI',
description: 'Use client storage to cache data to perform speed of application.',
options: {
form: [
{
display: '',
null: true,
name: 'ui_client_storage',
tag: 'boolean',
options: {
true => 'yes',
false => 'no',
},
},
],
},
state: false,
preferences: { prio: 2 },
frontend: true
)
end
end

View file

@ -0,0 +1,265 @@
class AddSettingOnlineService < ActiveRecord::Migration
def up
# return if it's a new setup
return if !Setting.find_by(name: 'system_init_done')
Setting.create_or_update(
title: 'System Init Done',
name: 'system_init_done',
area: 'Core',
description: 'Defines if application is in init mode.',
options: {},
state: Setting.get('system_init_done'),
preferences: { online_service_disable: true },
frontend: true
)
Setting.create_or_update(
title: 'Developer System',
name: 'developer_mode',
area: 'Core::Develop',
description: 'Defines if application is in developer mode (useful for developer, all users have the same password, password reset will work without email delivery).',
options: {},
state: Setting.get('developer_mode'),
preferences: { online_service_disable: true },
frontend: true
)
Setting.create_or_update(
title: 'Online Service',
name: 'system_online_service',
area: 'Core',
description: 'Defines if application is used as online service.',
options: {},
state: Setting.get('system_online_service'),
preferences: { online_service_disable: true },
frontend: true
)
Setting.create_or_update(
title: 'SystemID',
name: 'system_id',
area: 'System::Base',
description: 'Defines the system identifier. Every ticket number contains this ID. This ensures that only tickets which belong to your system will be processed as follow-ups (useful when communicating between two instances of Zammad).',
options: {
form: [
{
display: '',
null: true,
name: 'system_id',
tag: 'select',
options: {
'10' => '10',
'11' => '11',
'12' => '12',
'13' => '13',
},
},
],
},
state: '10',
preferences: { online_service_disable: true },
frontend: true
)
Setting.create_or_update(
title: 'Fully Qualified Domain Name',
name: 'fqdn',
area: 'System::Base',
description: 'Defines the fully qualified domain name of the system. This setting is used as a variable, #{setting.fqdn} which is found in all forms of messaging used by the application, to build links to the tickets within your system.',
options: {
form: [
{
display: '',
null: false,
name: 'fqdn',
tag: 'input',
},
],
},
state: Setting.get('fqdn'),
preferences: { online_service_disable: true },
frontend: true
)
Setting.create_or_update(
title: 'websocket port',
name: 'websocket_port',
area: 'System::WebSocket',
description: 'Defines the port of the websocket server.',
options: {
form: [
{
display: '',
null: false,
name: 'websocket_port',
tag: 'input',
},
],
},
state: Setting.get('websocket_port'),
preferences: { online_service_disable: true },
frontend: true
)
Setting.create_or_update(
title: 'http type',
name: 'http_type',
area: 'System::Base',
description: 'Defines the type of protocol, used by the web server, to serve the application. If https protocol will be used instead of plain http, it must be specified in here. Since this has no affect on the web server\'s settings or behavior, it will not change the method of access to the application and, if it is wrong, it will not prevent you from logging into the application. This setting is used as a variable, #{setting.http_type} which is found in all forms of messaging used by the application, to build links to the tickets within your system.',
options: {
form: [
{
display: '',
null: true,
name: 'http_type',
tag: 'select',
options: {
'https' => 'https',
'http' => 'http',
},
},
],
},
state: Setting.get('http_type'),
preferences: { online_service_disable: true },
frontend: true
)
Setting.create_or_update(
title: 'Storage Mechanism',
name: 'storage',
area: 'System::Storage',
description: '"Database" stores all attachments in the database (not recommended for storing large amounts of data). "Filesystem" stores the data on the filesystem. You can switch between the modules even on a system that is already in production without any loss of data.',
options: {
form: [
{
display: '',
null: true,
name: 'storage',
tag: 'select',
options: {
'DB' => 'Database',
'FS' => 'Filesystem',
},
},
],
},
state: Setting.get('storage'),
preferences: { online_service_disable: true },
frontend: false
)
Setting.create_or_update(
title: 'Max. Email Size',
name: 'postmaster_max_size',
area: 'Email::Base',
description: 'Maximal size in MB of emails.',
options: {
form: [
{
display: '',
null: true,
name: 'postmaster_max_size',
tag: 'select',
options: {
1 => 1,
2 => 2,
3 => 3,
4 => 4,
5 => 5,
6 => 6,
7 => 7,
8 => 8,
9 => 9,
10 => 10,
11 => 11,
12 => 12,
13 => 13,
14 => 14,
15 => 15,
16 => 16,
17 => 17,
18 => 18,
19 => 19,
20 => 20,
},
},
],
},
state: Setting.get('postmaster_max_size'),
preferences: { online_service_disable: true },
frontend: false
)
Setting.create_or_update(
title: 'Notification Sender',
name: 'notification_sender',
area: 'Email::Base',
description: 'Defines the sender of email notifications.',
options: {
form: [
{
display: '',
null: false,
name: 'notification_sender',
tag: 'input',
},
],
},
state: Setting.get('notification_sender'),
preferences: { online_service_disable: true },
frontend: false
)
Setting.create_or_update(
title: 'Elasticsearch Endpoint URL',
name: 'es_url',
area: 'SearchIndex::Elasticsearch',
description: 'Define endpoint of Elastic Search.',
state: Setting.get('es_url'),
preferences: { online_service_disable: true },
frontend: false
)
Setting.create_or_update(
title: 'Elasticsearch Endpoint User',
name: 'es_user',
area: 'SearchIndex::Elasticsearch',
description: 'Define http basic auth user of Elasticsearch.',
state: Setting.get('es_user'),
preferences: { online_service_disable: true },
frontend: false
)
Setting.create_or_update(
title: 'Elastic Search Endpoint Password',
name: 'es_password',
area: 'SearchIndex::Elasticsearch',
description: 'Define http basic auth password of Elasticsearch.',
state: Setting.get('es_password'),
preferences: { online_service_disable: true },
frontend: false
)
Setting.create_or_update(
title: 'Elastic Search Endpoint Index',
name: 'es_index',
area: 'SearchIndex::Elasticsearch',
description: 'Define Elasticsearch index name.',
state: Setting.get('es_index'),
preferences: { online_service_disable: true },
frontend: false
)
Setting.create_or_update(
title: 'Elastic Search Attachment Extentions',
name: 'es_attachment_ignore',
area: 'SearchIndex::Elasticsearch',
description: 'Define attachment extentions which are ignored for Elasticsearch.',
state: Setting.get('es_attachment_ignore'),
preferences: { online_service_disable: true },
frontend: false
)
Setting.create_or_update(
title: 'Elastic Search Attachment Size',
name: 'es_attachment_max_size_in_mb',
area: 'SearchIndex::Elasticsearch',
description: 'Define max. attachment size for Elasticsearch.',
state: Setting.get('es_attachment_max_size_in_mb'),
preferences: { online_service_disable: true },
frontend: false
)
end
end

View file

@ -0,0 +1,17 @@
class FacebookArticleTypes < ActiveRecord::Migration
def up
facebook_at = Ticket::Article::Type.find_by( name: 'facebook' )
return if !facebook_at
facebook_at.name = 'facebook feed post'
facebook_at.save
Ticket::Article::Type.create(
name: 'facebook feed comment',
communication: true,
updated_by_id: 1,
created_by_id: 1
)
end
end

View file

@ -13,6 +13,7 @@ Setting.create_if_not_exists(
description: 'Defines if application is in init mode.',
options: {},
state: false,
preferences: { online_service_disable: true },
frontend: true
)
Setting.create_if_not_exists(
@ -22,6 +23,7 @@ Setting.create_if_not_exists(
description: 'Defines if application is in developer mode (useful for developer, all users have the same password, password reset will work without email delivery).',
options: {},
state: false,
preferences: { online_service_disable: true },
frontend: true
)
Setting.create_if_not_exists(
@ -31,6 +33,7 @@ Setting.create_if_not_exists(
description: 'Defines if application is used as online service.',
options: {},
state: false,
preferences: { online_service_disable: true },
frontend: true
)
Setting.create_if_not_exists(
@ -49,7 +52,7 @@ Setting.create_if_not_exists(
],
},
preferences: { render: true, session_check: true, prio: 1 },
state: 'Zammad',
state: 'Zammad Helpdesk',
frontend: true
)
Setting.create_if_not_exists(
@ -75,7 +78,7 @@ Setting.create_if_not_exists(
title: 'Organization',
name: 'organization',
area: 'System::Branding',
description: 'Will be shown in the app and is included in email headers.',
description: 'Will be shown in the app and is included in email footers.',
options: {
form: [
{
@ -113,6 +116,7 @@ Setting.create_if_not_exists(
],
},
state: '10',
preferences: { online_service_disable: true },
frontend: true
)
Setting.create_if_not_exists(
@ -131,6 +135,7 @@ Setting.create_if_not_exists(
],
},
state: 'zammad.example.com',
preferences: { online_service_disable: true },
frontend: true
)
Setting.create_if_not_exists(
@ -149,6 +154,7 @@ Setting.create_if_not_exists(
],
},
state: '6042',
preferences: { online_service_disable: true },
frontend: true
)
Setting.create_if_not_exists(
@ -171,6 +177,7 @@ Setting.create_if_not_exists(
],
},
state: 'http',
preferences: { online_service_disable: true },
frontend: true
)
@ -194,35 +201,39 @@ Setting.create_if_not_exists(
],
},
state: 'DB',
preferences: { online_service_disable: true },
frontend: false
)
Setting.create_if_not_exists(
title: 'Geo Location Backend',
name: 'geo_location_backend',
area: 'System::Geo',
description: 'Defines the backend for geo location lookups.',
title: 'Image Service',
name: 'image_backend',
area: 'System::Services',
description: 'Defines the backend for user and organization image lookups.',
options: {
form: [
{
display: '',
null: true,
name: 'geo_location_backend',
name: 'image_backend',
tag: 'select',
options: {
'' => '-',
'GeoLocation::Gmaps' => 'Google Maps',
'Service::Image::Zammad' => 'Zammad Image Service',
},
},
],
},
state: 'GeoLocation::Gmaps',
state: 'Service::Image::Zammad',
preferences: { prio: 1 },
frontend: false
)
Setting.create_if_not_exists(
title: 'Geo IP Backend',
title: 'Geo IP Service',
name: 'geo_ip_backend',
area: 'System::Geo',
description: 'Defines the backend for geo ip lookups.',
area: 'System::Services',
description: 'Defines the backend for geo IP lookups. Show also location of an IP address if an IP address is shown.',
options: {
form: [
{
@ -232,12 +243,37 @@ Setting.create_if_not_exists(
tag: 'select',
options: {
'' => '-',
'GeoIp::ZammadGeoIp' => 'Zammad GeoIP Service',
'Service::GeoIp::Zammad' => 'Zammad GeoIP Service',
},
},
],
},
state: 'GeoIp::ZammadGeoIp',
state: 'Service::GeoIp::Zammad',
preferences: { prio: 2 },
frontend: false
)
Setting.create_if_not_exists(
title: 'Geo Location Service',
name: 'geo_location_backend',
area: 'System::Services',
description: 'Defines the backend for geo location lookups to store geo locations for addresses.',
options: {
form: [
{
display: '',
null: true,
name: 'geo_location_backend',
tag: 'select',
options: {
'' => '-',
'Service::GeoLocation::Gmaps' => 'Google Maps',
},
},
],
},
state: 'Service::GeoLocation::Gmaps',
preferences: { prio: 3 },
frontend: false
)
@ -245,7 +281,7 @@ Setting.create_if_not_exists(
title: 'Send client stats',
name: 'ui_send_client_stats',
area: 'System::UI',
description: 'Send client stats to central server.',
description: 'Send client stats/error message to central server to improve the usability.',
options: {
form: [
{
@ -261,6 +297,7 @@ Setting.create_if_not_exists(
],
},
state: true,
preferences: { prio: 1 },
frontend: true
)
Setting.create_if_not_exists(
@ -283,6 +320,7 @@ Setting.create_if_not_exists(
],
},
state: false,
preferences: { prio: 2 },
frontend: true
)
@ -1034,6 +1072,7 @@ Setting.create_if_not_exists(
],
},
state: 10,
preferences: { online_service_disable: true },
frontend: false
)
@ -1078,6 +1117,7 @@ Setting.create_if_not_exists(
],
},
state: 'Notification Master <noreply@#{config.fqdn}>',
preferences: { online_service_disable: true },
frontend: false
)
@ -1140,6 +1180,7 @@ Setting.create_if_not_exists(
area: 'SearchIndex::Elasticsearch',
description: 'Define endpoint of Elastic Search.',
state: '',
preferences: { online_service_disable: true },
frontend: false
)
Setting.create_if_not_exists(
@ -1148,6 +1189,7 @@ Setting.create_if_not_exists(
area: 'SearchIndex::Elasticsearch',
description: 'Define http basic auth user of Elasticsearch.',
state: '',
preferences: { online_service_disable: true },
frontend: false
)
Setting.create_if_not_exists(
@ -1156,6 +1198,7 @@ Setting.create_if_not_exists(
area: 'SearchIndex::Elasticsearch',
description: 'Define http basic auth password of Elasticsearch.',
state: '',
preferences: { online_service_disable: true },
frontend: false
)
Setting.create_if_not_exists(
@ -1164,6 +1207,7 @@ Setting.create_if_not_exists(
area: 'SearchIndex::Elasticsearch',
description: 'Define Elasticsearch index name.',
state: 'zammad',
preferences: { online_service_disable: true },
frontend: false
)
Setting.create_if_not_exists(
@ -1172,6 +1216,7 @@ Setting.create_if_not_exists(
area: 'SearchIndex::Elasticsearch',
description: 'Define attachment extentions which are ignored for Elasticsearch.',
state: [ '.png', '.jpg', '.jpeg', '.mpeg', '.mpg', '.mov', '.bin', '.exe', '.box', '.mbox' ],
preferences: { online_service_disable: true },
frontend: false
)
Setting.create_if_not_exists(
@ -1180,6 +1225,7 @@ Setting.create_if_not_exists(
area: 'SearchIndex::Elasticsearch',
description: 'Define max. attachment size for Elasticsearch.',
state: 50,
preferences: { online_service_disable: true },
frontend: false
)
@ -1455,9 +1501,10 @@ Ticket::Article::Type.create_if_not_exists( id: 4, name: 'fax', communication: t
Ticket::Article::Type.create_if_not_exists( id: 5, name: 'phone', communication: true )
Ticket::Article::Type.create_if_not_exists( id: 6, name: 'twitter status', communication: true )
Ticket::Article::Type.create_if_not_exists( id: 7, name: 'twitter direct-message', communication: true )
Ticket::Article::Type.create_if_not_exists( id: 8, name: 'facebook', communication: true )
Ticket::Article::Type.create_if_not_exists( id: 9, name: 'note', communication: false )
Ticket::Article::Type.create_if_not_exists( id: 10, name: 'web', communication: true )
Ticket::Article::Type.create_if_not_exists( id: 8, name: 'facebook feed post', communication: true )
Ticket::Article::Type.create_if_not_exists( id: 9, name: 'facebook feed comment', communication: true )
Ticket::Article::Type.create_if_not_exists( id: 10, name: 'note', communication: false )
Ticket::Article::Type.create_if_not_exists( id: 11, name: 'web', communication: true )
Ticket::Article::Sender.create_if_not_exists( id: 1, name: 'Agent' )
Ticket::Article::Sender.create_if_not_exists( id: 2, name: 'Customer' )

View file

@ -111,7 +111,7 @@ returns
# fetch org logo
if admin_user.email
Zammad::BigData::Organization.suggest_system_image(admin_user.email)
Service::Image.organization_suggest(admin_user.email)
end
}
end

View file

@ -130,37 +130,41 @@ class Facebook
ticket.save
end
user = to_user(comment)
user = to_user(post)
return if !user
feed_post = {
from: user.name,
from: "#{user.firstname} #{user.lastname}",
body: post['message'],
message_id: post['id'],
type: Ticket::Article::Type.find_by( name: 'facebook feed post' ),
type_id: Ticket::Article::Type.find_by( name: 'facebook feed post' ).id,
}
articles = []
articles.push( feed_post )
post['comments']['data'].each { |comment|
if post['comments']
post['comments']['data'].each { |comment|
user = to_user(comment)
user = to_user(comment)
next if !user
next if !user
post_comment = {
from: user.name,
body: comment['message'],
message_id: comment['id'],
type: Ticket::Article::Type.find_by( name: 'facebook feed comment' ),
post_comment = {
from: "#{user.firstname} #{user.lastname}",
body: comment['message'],
message_id: comment['id'],
type_id: Ticket::Article::Type.find_by( name: 'facebook feed comment' ).id,
}
articles.push( post_comment )
# TODO: sub-comments
# comment_data = @client.get_object( comment['id'] )
}
articles.push( post_comment )
end
# TODO: sub-comments
# comment_data = @client.get_object( comment['id'] )
}
inverted_articles = articles.reverse
articles.invert.each { |article|
inverted_articles.each { |article|
break if Ticket::Article.find_by( message_id: article[:message_id] )
@ -168,6 +172,9 @@ class Facebook
to: @account['name'],
ticket_id: ticket.id,
internal: false,
sender_id: Ticket::Article::Sender.lookup( name: 'Customer' ).id,
created_by_id: 1,
updated_by_id: 1,
}.merge( article )
Ticket::Article.create( article )
@ -204,12 +211,11 @@ class Facebook
def from_article(article)
post = nil
# TODO: article[:type] == 'facebook feed post'
if article[:type] == 'facebook feed comment'
Rails.logger.debug 'Create feed comment from article...'
post = @client.put_wall_post(article[:body], {}, article[:in_reply_to])
post = @client.put_comment(article[:in_reply_to], article[:body])
else
fail "Can't handle unknown facebook article type '#{article[:type]}'."
end

View file

@ -1,49 +0,0 @@
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
class GeoLocation
include ApplicationLib
=begin
lookup lat and lng for address
result = GeoLocation.geocode( 'Marienstrasse 13, 10117 Berlin' )
returns
result = [ 4.21312, 1.3123 ]
=end
def self.geocode(address)
# load backend
backend = load_adapter_by_setting( 'geo_location_backend' )
return if !backend
# db lookup
backend.geocode(address)
end
=begin
lookup address for lat and lng
result = GeoLocation.reverse_geocode( 4.21312, 1.3123 )
returns
result = 'some address'
=end
def self.reverse_geocode(lat, lng)
# load backend
backend = load_adapter_by_setting( 'geo_location_backend' )
return if !backend
# db lookup
backend.reverse_geocode(lat, lng)
end
end

View file

@ -1,13 +1,14 @@
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
class GeoIp
include ApplicationLib
module Service
class GeoIp
include ApplicationLib
=begin
lookup location based on ip or hostname
result = GeoIp.location( '172.0.0.1' )
result = Service::GeoIp.location( '172.0.0.1' )
returns
@ -27,13 +28,14 @@ returns
=end
def self.location(address)
def self.location(address)
# load backend
backend = load_adapter_by_setting( 'geo_ip_backend' )
return if !backend
# load backend
backend = load_adapter_by_setting( 'geo_ip_backend' )
return if !backend
# db lookup
backend.location(address)
# db lookup
backend.location(address)
end
end
end

View file

@ -2,7 +2,7 @@
require 'cache'
class GeoIp::ZammadGeoIp
class Service::GeoIp::Zammad
def self.location(address)
# check cache

View file

@ -0,0 +1,51 @@
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
module Service
class GeoLocation
include ApplicationLib
=begin
lookup lat and lng for address
result = Service::GeoLocation.geocode( 'Marienstrasse 13, 10117 Berlin' )
returns
result = [ 4.21312, 1.3123 ]
=end
def self.geocode(address)
# load backend
backend = load_adapter_by_setting( 'geo_location_backend' )
return if !backend
# db lookup
backend.geocode(address)
end
=begin
lookup address for lat and lng
result = GeoLocation.reverse_geocode( 4.21312, 1.3123 )
returns
result = 'some address'
=end
def self.reverse_geocode(lat, lng)
# load backend
backend = load_adapter_by_setting( 'geo_location_backend' )
return if !backend
# db lookup
backend.reverse_geocode(lat, lng)
end
end
end

View file

@ -1,6 +1,6 @@
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
class GeoLocation::Gmaps
class Service::GeoLocation::Gmaps
def self.geocode(address)
url = "http://maps.googleapis.com/maps/api/geocode/json?address=#{CGI.escape address}&sensor=true"

79
lib/service/image.rb Normal file
View file

@ -0,0 +1,79 @@
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
module Service
class Image
include ApplicationLib
=begin
lookup user image based on email address
file = Service::Image.user( 'skywalker@zammad.org' )
returns
{
content: content,
mime_type: mime_type,
}
=end
def self.user(address)
# load backend
backend = load_adapter_by_setting( 'image_backend' )
return if !backend
backend.user(address)
end
=begin
lookup organization image based on domain
file = Service::Image.organization('edenhofer.de')
file = Service::Image.organization('user@edenhofer.de') # will just use domain
returns
{
content: content,
mime_type: mime_type,
}
=end
def self.organization(domain)
# load backend
backend = load_adapter_by_setting( 'image_backend' )
return if !backend
backend.organization(domain)
end
=begin
find organization image suggestion
result = Service::Image.organization_suggest('edenhofer.de')
returns
true # or false
=end
def self.organization_suggest(domain)
# load backend
backend = load_adapter_by_setting( 'image_backend' )
return if !backend
backend.organization_suggest(domain)
end
end
end

View file

@ -0,0 +1,74 @@
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
class Service::Image::Zammad
# rubocop:disable Style/ClassVars
@@api_host = 'https://images.zammad.com'
@@open_timeout = 4
@@read_timeout = 6
def self.user(email)
# fetch image
response = UserAgent.post(
"#{@@api_host}/api/v1/person/image",
{
email: email,
},
{
open_timeout: @@open_timeout,
read_timeout: @@read_timeout,
},
)
if !response.success?
Rails.logger.info "Can't fetch image for '#{email}' (maybe no avatar available), http code: #{response.code}"
return
end
Rails.logger.info "Fetched image for '#{email}', http code: #{response.code}"
mime_type = 'image/jpeg'
{
content: response.body,
mime_type: mime_type,
}
end
def self.organization(domain)
# strip, just use domain name
domain = domain.sub(/^.+?@(.+?)$/, '\1')
# fetch org logo
response = UserAgent.post(
"#{@@api_host}/api/v1/organization/image",
{
domain: domain
},
{
open_timeout: @@open_timeout,
read_timeout: @@read_timeout,
},
)
if !response.success?
Rails.logger.info "Can't fetch image for '#{domain}' (maybe no avatar available), http code: #{response.code}"
return
end
Rails.logger.info "Fetched image for '#{domain}', http code: #{response.code}"
mime_type = 'image/png'
{
content: response.body,
mime_type: mime_type,
}
end
def self.organization_suggest(domain)
image = organization(domain)
return false if !image
# store image 1:1
product_logo = StaticAssets.store_raw( image[:content], image[:mime_type] )
Setting.set('product_logo', product_logo)
true
end
end

View file

@ -97,23 +97,32 @@ class Tweet
Rails.logger.debug group_id.inspect
if tweet.class.to_s == 'Twitter::DirectMessage'
ticket = Ticket.find_by(
customer_id: user.id,
state: Ticket::State.where.not(
state_type_id: Ticket::StateType.where(
name: 'closed',
article = Ticket::Article.find_by(
from: 'me_bauer',
type_id: Ticket::Article::Type.find_by( name: 'twitter direct-message' ).id,
)
if article
ticket = Ticket.find_by(
id: article.ticket_id,
customer_id: user.id,
state: Ticket::State.where.not(
state_type_id: Ticket::StateType.where(
name: 'closed',
)
)
)
)
return ticket if ticket
return ticket if ticket
end
end
Ticket.create(
customer_id: user.id,
title: "#{tweet.text[0, 37]}...",
group_id: group_id,
state: Ticket::State.find_by( name: 'new' ),
priority: Ticket::Priority.find_by( name: '2 normal' ),
state_id: Ticket::State.find_by( name: 'new' ).id,
priority_id: Ticket::Priority.find_by( name: '2 normal' ).id,
)
end
@ -153,8 +162,8 @@ class Tweet
message_id: tweet.id,
ticket_id: ticket.id,
in_reply_to: in_reply_to,
type: Ticket::Article::Type.find_by( name: article_type ),
sender: Ticket::Article::Sender.find_by( name: 'Customer' ),
type_id: Ticket::Article::Type.find_by( name: article_type ).id,
sender_id: Ticket::Article::Sender.find_by( name: 'Customer' ).id,
internal: false,
)
end

View file

@ -1,10 +0,0 @@
module Zammad
module BigData
class Base
# rubocop:disable Style/ClassVars
@@api_host = 'https://bigdata.zammad.com'
@@open_timeout = 4
@@read_timeout = 6
end
end
end

View file

@ -1,69 +0,0 @@
module Zammad
module BigData
class Organization < Zammad::BigData::Base
=begin
file = Zammad::BigData::Organization.image('edenhofer.de')
file = Zammad::BigData::Organization.image('user@edenhofer.de') # will just use domain
returns
{
content: content,
mime_type: mime_type,
}
=end
def self.image(domain)
# strip, just use domain name
domain = domain.sub(/^.+?@(.+?)$/, '\1')
# fetch org logo
response = UserAgent.post(
"#{@@api_host}/api/v1/organization/image",
{
domain: domain
},
{
open_timeout: @@open_timeout,
read_timeout: @@read_timeout,
},
)
if !response.success?
Rails.logger.info "Can't fetch image for '#{domain}' (maybe no avatar available), http code: #{response.code}"
return
end
Rails.logger.info "Fetched image for '#{domain}', http code: #{response.code}"
mime_type = 'image/png'
{
content: response.body,
mime_type: mime_type,
}
end
=begin
result = Zammad::BigData::Organization.suggest_system_image('edenhofer.de')
returns
true # or false
=end
def self.suggest_system_image(domain)
image = self.image(domain)
return false if !image
# store image 1:1
product_logo = StaticAssets.store_raw( image[:content], image[:mime_type] )
Setting.set('product_logo', product_logo)
true
end
end
end
end

View file

@ -1,45 +0,0 @@
module Zammad
module BigData
class User < Zammad::BigData::Base
=begin
file = Zammad::BigData::User.image('client@edenhofer.de')
returns
{
content: content,
mime_type: mime_type,
}
=end
def self.image(email)
# fetch logo
response = UserAgent.post(
"#{@@api_host}/api/v1/person/image",
{
email: email,
},
{
open_timeout: @@open_timeout,
read_timeout: @@read_timeout,
},
)
if !response.success?
Rails.logger.info "Can't fetch image for '#{email}' (maybe no avatar available), http code: #{response.code}"
return
end
Rails.logger.info "Fetched image for '#{email}', http code: #{response.code}"
mime_type = 'image/jpeg'
{
content: response.body,
mime_type: mime_type,
}
end
end
end
end

View file

@ -50,6 +50,15 @@ sleep 15
#export REMOTE_URL='http://192.168.178.32:4444/wd/hub'
#export REMOTE_URL='http://192.168.178.45:4444/wd/hub'
export RAILS_ENV=test
echo "rake db:drop"
time rake db:drop
echo "rake db:create"
time rake db:create
echo "rake db:migrate"
time rake db:migrate
rake test:browser["BROWSER_URL=http://localhost:4444"]
#rake test:browser["BROWSER_URL=http://localhost:4444 BROWSER=chrome"]
#rake test:browser["BROWSER_URL=http://192.168.178.28:4444"]

View file

@ -59,11 +59,10 @@ if ARGV[0] != 'start' && ARGV[0] != 'stop'
exit
end
puts "Starting websocket server on #{@options[:b]}:#{@options[:p]} (secure:#{@options[:s]},pid:#{@options[:i]})"
#puts options.inspect
if ARGV[0] == 'stop'
puts "Stopping websocket server (pid:#{@options[:i]})"
# read pid
pid = File.open( @options[:i].to_s ).read
pid.gsub!(/\r|\n/, '')
@ -74,6 +73,8 @@ if ARGV[0] == 'stop'
end
if ARGV[0] == 'start' && @options[:d]
puts "Starting websocket server on #{@options[:b]}:#{@options[:p]} (secure:#{@options[:s]},pid:#{@options[:i]})"
Daemons.daemonize
# create pid file

View file

@ -206,7 +206,7 @@ class AgentTicketActionsLevel3Test < TestCase
css: '.content.active .js-reset',
browser: browser2,
)
sleep 2
sleep 5
ticket_verify(
browser: browser2,
data: {

View file

@ -104,11 +104,11 @@ class AgentTicketOverviewLevel0Test < TestCase
# check if number and article count is shown
match(
css: '.active table th:nth-child(3)',
css: '.active table th:nth-child(7)',
value: '#',
)
match(
css: '.active table th:nth-child(8)',
css: '.active table th:nth-child(4)',
value: 'Article#',
)
@ -118,11 +118,11 @@ class AgentTicketOverviewLevel0Test < TestCase
# check if number and article count is shown
match(
css: '.active table th:nth-child(3)',
css: '.active table th:nth-child(7)',
value: '#',
)
match(
css: '.active table th:nth-child(8)',
css: '.active table th:nth-child(4)',
value: 'Article#',
)

View file

@ -9,6 +9,22 @@ class TestCase < Test::Unit::TestCase
ENV['BROWSER'] || 'firefox'
end
def profile
browser_profile = nil
if browser == 'firefox'
browser_profile = Selenium::WebDriver::Firefox::Profile.new
browser_profile['intl.locale.matchOS'] = false
browser_profile['intl.accept_languages'] = 'en-US'
browser_profile['general.useragent.locale'] = 'en-US'
elsif browser == 'chrome'
browser_profile = Selenium::WebDriver::Chrome::Profile.new
browser_profile['intl.accept_languages'] = 'en'
end
browser_profile
end
def browser_support_cookies
if browser =~ /(internet_explorer|ie)/i
return false
@ -25,7 +41,7 @@ class TestCase < Test::Unit::TestCase
@browsers = {}
end
if !ENV['REMOTE_URL'] || ENV['REMOTE_URL'].empty?
local_browser = Selenium::WebDriver.for( browser.to_sym )
local_browser = Selenium::WebDriver.for( browser.to_sym, profile: profile )
browser_instance_preferences(local_browser)
@browsers[local_browser.hash] = local_browser
return local_browser
@ -1226,7 +1242,7 @@ wait untill text in selector disabppears
found = nil
(1..10).each {
next if found
break if found
begin
text = instance.find_elements( { css: '.content.active .js-reset' } )[0].text
@ -1240,6 +1256,7 @@ wait untill text in selector disabppears
}
if !found
screenshot( browser: instance, comment: 'ticket_update_discard_message_failed' )
fail 'no discard message found'
end
end

View file

@ -0,0 +1,154 @@
# encoding: utf-8
require 'integration_test_helper'
class FacebookTest < ActiveSupport::TestCase
# set system mode to done / to activate
Setting.set('system_init_done', true)
# needed to check correct behavior
Group.create_if_not_exists(
id: 2,
name: 'Facebook',
note: 'All Facebook feed posts.',
updated_by_id: 1,
created_by_id: 1
)
provider_key = 'CAACEdEose0cBAC56WJvrGb5avKbTlH0c7P4xZCZBfT8zG4nkgEWeFKGnnpNZC8xeedXzmqZCxEUrAumX245T4MborvAmRW52PSpuDiXwXXSMjYaZCJOih5v6CsP3xrZAfGxhPWBbI8dSoquBv8eRbUAMSir9SDSoDeKJSdSfhuytqx5wfveE8YibzT2ZAwYz0d7d2QZAN4b10d9j9UpBhXCCCahj4hyk9JQZD'
consumer_key = 'CAACEdEose0cBAHZCXAQ68snZBf2C7jT6G7pVXaWajbZCZAZAFWRZAVUb9FAMXHZBQECZBX0iL5qOeTsZA0mnR0586XTq9vYiWP8Y3qCzftrd9hnsP7J9VB6APnR67NEdY8SozxIFtctQA9Xp4Lb8lbxBmig2v5oXRIH513kImPYXJoCFUlQs0aJeZBCtRG6BekfPs5GPZB8tieQE3yGgtZBTZA3HI2TtQLZBNXyLAZD'
provider_page_name = 'Hansi Merkurs Hutfabrik'
provider_options = {
auth: {
access_token: provider_key
},
sync: {
page: provider_page_name,
group_id: 2,
limit: 1,
}
}
# add channel
current = Channel.where( adapter: 'Facebook' )
current.each(&:destroy)
Channel.create(
adapter: 'Facebook',
area: 'Facebook::Inbound',
options: provider_options,
active: true,
created_by_id: 1,
updated_by_id: 1,
)
test 'pages' do
provider_options_clone = provider_options
provider_options_clone[:sync].delete(:page)
facebook = Facebook.new( provider_options_clone )
pages = facebook.pages
page_found = false
pages.each { |page|
next if page[:name] != provider_page_name
page_found = true
}
assert( page_found, "Page lookup for '#{provider_page_name}'" )
end
test 'feed post to ticket' do
consumer_client = Koala::Facebook::API.new( consumer_key )
feed_post = "I've got an issue with my hat, serial number ##{rand(9999)}"
facebook = Facebook.new( provider_options )
post = consumer_client.put_wall_post(feed_post, {}, facebook.account['id'])
# fetch check system account
Channel.fetch
# check if first article has been created
article = Ticket::Article.find_by( message_id: post['id'] )
assert( article, "article post '#{post['id']}' imported" )
assert_equal( article.body, feed_post, 'ticket article inbound body' )
assert_equal( 1, article.ticket.articles.count, 'ticket article inbound count' )
assert_equal( feed_post, article.ticket.articles.last.body, 'ticket article inbound body' )
post_comment = "Any updates yet? It's urgent. I love my hat."
comment = consumer_client.put_comment(post['id'], post_comment)
# fetch check system account
Channel.fetch
# check if second article has been created
article = Ticket::Article.find_by( message_id: comment['id'] )
assert( article, "article comment '#{comment['id']}' imported" )
assert_equal( article.body, post_comment, 'ticket article inbound body' )
assert_equal( 2, article.ticket.articles.count, 'ticket article inbound count' )
assert_equal( post_comment, article.ticket.articles.last.body, 'ticket article inbound body' )
end
test 'feed post and comment reply' do
consumer_client = Koala::Facebook::API.new( consumer_key )
feed_post = "I've got an issue with my hat, serial number ##{rand(9999)}"
facebook = Facebook.new( provider_options )
post = consumer_client.put_wall_post(feed_post, {}, facebook.account['id'])
# fetch check system account
Channel.fetch
# check if first article has been created
article = Ticket::Article.find_by( message_id: post['id'] )
reply_text = "What's your issue Bernd?"
# reply via ticket
outbound_article = Ticket::Article.create(
ticket_id: article.ticket.id,
body: reply_text,
in_reply_to: post['id'],
type: Ticket::Article::Type.find_by( name: 'facebook feed comment' ),
sender: Ticket::Article::Sender.find_by( name: 'Agent' ),
internal: false,
updated_by_id: 1,
created_by_id: 1,
)
assert( outbound_article, 'outbound article created' )
assert_equal( outbound_article.ticket.articles.count, 2, 'ticket article outbound count' )
post_comment = 'The peacock feather is fallen off.'
comment = consumer_client.put_comment(post['id'], post_comment)
# fetch check system account
Channel.fetch
reply_text = "Please send it to our address and add the ticket number #{article.ticket.number}."
# reply via ticket
outbound_article = Ticket::Article.create(
ticket_id: article.ticket.id,
body: reply_text,
in_reply_to: comment['id'],
type: Ticket::Article::Type.find_by( name: 'facebook feed comment' ),
sender: Ticket::Article::Sender.find_by( name: 'Agent' ),
internal: false,
updated_by_id: 1,
created_by_id: 1,
)
assert( outbound_article, 'outbound article created' )
assert_equal( outbound_article.ticket.articles.count, 4, 'ticket article outbound count' )
end
end

View file

@ -6,7 +6,7 @@ class GeoIpTest < ActiveSupport::TestCase
# check
test 'check some results' do
result = GeoIp.location( '127.0.0.0.1' )
result = Service::GeoIp.location( '127.0.0.0.1' )
assert(result)
assert_equal(nil, result['country_name'])
assert_equal(nil, result['city_name'])
@ -15,7 +15,7 @@ class GeoIpTest < ActiveSupport::TestCase
assert_equal(nil, result['latitude'])
assert_equal(nil, result['longitude'])
result = GeoIp.location( '195.65.29.254' )
result = Service::GeoIp.location( '195.65.29.254' )
assert(result)
assert_equal('Switzerland', result['country_name'])
assert_equal('Regensdorf', result['city_name'])
@ -24,7 +24,7 @@ class GeoIpTest < ActiveSupport::TestCase
assert_equal(47.4299, result['latitude'])
assert_equal(8.465100000000007, result['longitude'])
result = GeoIp.location( '134.109.140.74' )
result = Service::GeoIp.location( '134.109.140.74' )
assert(result)
assert_equal('Germany', result['country_name'])
assert_equal('Chemnitz', result['city_name'])
@ -33,7 +33,7 @@ class GeoIpTest < ActiveSupport::TestCase
assert_equal(50.83330000000001, result['latitude'])
assert_equal(12.916699999999992, result['longitude'])
result = GeoIp.location( '46.253.55.170' )
result = Service::GeoIp.location( '46.253.55.170' )
assert(result)
assert_equal('Germany', result['country_name'])
assert_equal('Halle', result['city_name'])
@ -42,7 +42,7 @@ class GeoIpTest < ActiveSupport::TestCase
assert_equal(51.5, result['latitude'])
assert_equal(12.0, result['longitude'])
result = GeoIp.location( '169.229.216.200' )
result = Service::GeoIp.location( '169.229.216.200' )
assert(result)
assert_equal('United States', result['country_name'])
assert_equal('Berkeley', result['city_name'])

View file

@ -6,22 +6,22 @@ class GeoLocationTest < ActiveSupport::TestCase
# check
test 'check simple results' do
result = GeoLocation.geocode( 'Marienstrasse 13, 10117 Berlin' )
result = Service::GeoLocation.geocode( 'Marienstrasse 13, 10117 Berlin' )
assert(result)
assert_equal(52.52204, result[0])
assert_equal(13.38319, result[1])
result = GeoLocation.geocode( 'Marienstrasse 13 10117 Berlin' )
result = Service::GeoLocation.geocode( 'Marienstrasse 13 10117 Berlin' )
assert(result)
assert_equal(52.52204, result[0])
assert_equal(13.38319, result[1])
result = GeoLocation.geocode( 'Martinsbruggstrasse 35, 9016 St. Gallen' )
result = Service::GeoLocation.geocode( 'Martinsbruggstrasse 35, 9016 St. Gallen' )
assert(result)
assert_equal(47.4366664, result[0])
assert_equal(9.409814899999999, result[1])
result = GeoLocation.geocode( 'Martinsbruggstrasse 35 9016 St. Gallen' )
result = Service::GeoLocation.geocode( 'Martinsbruggstrasse 35 9016 St. Gallen' )
assert(result)
assert_equal(47.4366664, result[0])
assert_equal(9.409814899999999, result[1])

View file

@ -147,8 +147,18 @@ class TwitterTest < ActiveSupport::TestCase
# fetch check system account
Channel.fetch
# check if follow up article has been created
article = Ticket::Article.find_by( message_id: tweet.id )
# fetch check system account
article = nil
(1..4).each {
Channel.fetch
# check if ticket and article has been created
article = Ticket::Article.find_by( message_id: tweet.id )
break if article
sleep 5
}
assert(article)
ticket = article.ticket
@ -220,25 +230,86 @@ class TwitterTest < ActiveSupport::TestCase
sleep 5
}
assert( article, 'inbound article created' )
assert( article, "inbound article '#{text}' created" )
ticket = article.ticket
assert( ticket, 'ticket of inbound article exists' )
assert( ticket.articles, 'ticket.articles exists' )
assert_equal( ticket.articles.count, 1, 'ticket article inbound count' )
assert_equal( 1, ticket.articles.count, 'ticket article inbound count' )
assert_equal( ticket.state.name, 'new' )
# reply via ticket
outbound_article = Ticket::Article.create(
ticket_id: ticket.id,
to: 'me_bauer',
body: text,
body: 'Will call you later!',
type: Ticket::Article::Type.find_by( name: 'twitter direct-message' ),
sender: Ticket::Article::Sender.find_by( name: 'Agent' ),
internal: false,
updated_by_id: 1,
created_by_id: 1,
)
ticket.state = Ticket::State.find_by( name: 'pending reminder' )
ticket.save
assert( outbound_article, 'outbound article created' )
assert_equal( outbound_article.ticket.articles.count, 2, 'ticket article outbound count' )
assert_equal( 2, outbound_article.ticket.articles.count, 'ticket article outbound count' )
text = 'Ok. ' + hash
dm = client.create_direct_message(
'armin_theo',
text,
)
assert( dm, "second dm with ##{hash} created" )
# fetch check system account
article = nil
(1..4).each {
Channel.fetch
# check if ticket and article has been created
article = Ticket::Article.find_by( message_id: dm.id )
break if article
sleep 5
}
assert( article, "inbound article '#{text}' created" )
ticket = article.ticket
assert( ticket, 'ticket of inbound article exists' )
assert( ticket.articles, 'ticket.articles exists' )
assert_equal( 3, ticket.articles.count, 'ticket article inbound count' )
assert_equal( ticket.state.name, 'open' )
# close dm ticket, next dm should open a new
ticket.state = Ticket::State.find_by( name: 'closed' )
ticket.save
text = 'Thanks for your call . I just have one question. ' + hash
dm = client.create_direct_message(
'armin_theo',
text,
)
assert( dm, "third dm with ##{hash} created" )
# fetch check system account
article = nil
(1..4).each {
Channel.fetch
# check if ticket and article has been created
article = Ticket::Article.find_by( message_id: dm.id )
break if article
sleep 5
}
assert( article, "inbound article '#{text}' created" )
ticket = article.ticket
assert( ticket, 'ticket of inbound article exists' )
assert( ticket.articles, 'ticket.articles exists' )
assert_equal( 1, ticket.articles.count, 'ticket article inbound count' )
assert_equal( ticket.state.name, 'new' )
end
end