From 6fa4f8d02b517d1296edc7229b0f35d886affaee Mon Sep 17 00:00:00 2001 From: Simon Kornblith Date: Mon, 9 Apr 2012 00:39:55 -0400 Subject: [PATCH] Server-side translation and attachment upload, part 1 --- chrome/content/zotero/locale/csl | 2 +- .../zotero/xpcom/connector/cachedTypes.js | 5 +- .../zotero/xpcom/connector/connector.js | 39 +- .../zotero/xpcom/connector/translate_item.js | 476 +++++++++++++++++- .../zotero/xpcom/translation/translate.js | 203 ++++++-- chrome/content/zotero/xpcom/utilities.js | 51 +- chrome/skin/default/zotero/progress_arcs.png | Bin 0 -> 1079 bytes .../zotero/treeitem-attachment-pdf.png | Bin 531 -> 692 bytes styles | 2 +- translators | 2 +- 10 files changed, 710 insertions(+), 70 deletions(-) create mode 100644 chrome/skin/default/zotero/progress_arcs.png diff --git a/chrome/content/zotero/locale/csl b/chrome/content/zotero/locale/csl index 843dcc3b3f..73f3050d7c 160000 --- a/chrome/content/zotero/locale/csl +++ b/chrome/content/zotero/locale/csl @@ -1 +1 @@ -Subproject commit 843dcc3b3f1f16ab317a9551be7446e098a9a7ee +Subproject commit 73f3050d7c66caeb338a54c53d79c1b4be0ea8aa diff --git a/chrome/content/zotero/xpcom/connector/cachedTypes.js b/chrome/content/zotero/xpcom/connector/cachedTypes.js index 466eb94dcf..9c7333842e 100644 --- a/chrome/content/zotero/xpcom/connector/cachedTypes.js +++ b/chrome/content/zotero/xpcom/connector/cachedTypes.js @@ -78,11 +78,10 @@ Zotero.Connector_Types = new function() { this.getImageSrc = function(idOrName) { var itemType = Zotero.Connector_Types["itemTypes"][idOrName]; - if(!itemType) return false; - var icon = itemType[6]/* icon */; + var icon = itemType ? itemType[6]/* icon */ : "treeitem-"+idOrName+".png"; if(Zotero.isBookmarklet) { - return ZOTERO_CONFIG.BOOKMARKLET_URL+"icons/"+icon; + return ZOTERO_CONFIG.BOOKMARKLET_URL+"images/"+icon; } else if(Zotero.isFx) { return "chrome://zotero/skin/"+icon; } else if(Zotero.isChrome) { diff --git a/chrome/content/zotero/xpcom/connector/connector.js b/chrome/content/zotero/xpcom/connector/connector.js index f529e85e23..94717d330d 100644 --- a/chrome/content/zotero/xpcom/connector/connector.js +++ b/chrome/content/zotero/xpcom/connector/connector.js @@ -27,8 +27,7 @@ Zotero.Connector = new function() { const CONNECTOR_URI = "http://127.0.0.1:23119/"; const CONNECTOR_API_VERSION = 2; - var _ieStandaloneIframeTarget; - var _ieConnectorCallbacks; + var _ieStandaloneIframeTarget, _ieConnectorCallbacks; this.isOnline = null; /** @@ -67,16 +66,26 @@ Zotero.Connector = new function() { Zotero.debug("Connector: Standalone found; trying IE hack"); _ieConnectorCallbacks = []; - Zotero.Messaging.addMessageListener("standaloneLoaded", function(data, event) { + var listener = function(event) { if(event.origin !== "http://127.0.0.1:23119") return; + event.stopPropagation(); - Zotero.debug("Connector: Standalone loaded"); - _ieStandaloneIframeTarget = iframe.contentWindow; - callback(true); - }); - Zotero.Messaging.addMessageListener("connectorResponse", function(data, event) { - if(event.origin !== "http://127.0.0.1:23119") return; + // If this is the first time the target was loaded, then this is a loaded + // event + if(!_ieStandaloneIframeTarget) { + Zotero.debug("Connector: Standalone loaded"); + _ieStandaloneIframeTarget = iframe.contentWindow; + callback(true); + return; + } + // Otherwise, this is a response event + try { + var data = JSON.parse(event.data); + } catch(e) { + Zotero.debug("Invalid JSON received: "+event.data); + return; + } var xhrSurrogate = { "status":data[1], "responseText":data[2], @@ -84,7 +93,13 @@ Zotero.Connector = new function() { }; _ieConnectorCallbacks[data[0]](xhrSurrogate); delete _ieConnectorCallbacks[data[0]]; - }); + }; + + if(window.addEventListener) { + window.addEventListener("message", listener, false); + } else { + window.attachEvent("onmessage", function() { listener(event); }); + } var iframe = document.createElement("iframe"); iframe.src = "http://127.0.0.1:23119/connector/ieHack"; @@ -169,10 +184,10 @@ Zotero.Connector = new function() { }; if(Zotero.isIE) { // IE requires XDR for CORS - if(_ieStandaloneIframeTarget !== undefined) { + if(_ieStandaloneIframeTarget) { var requestID = Zotero.Utilities.randomString(); _ieConnectorCallbacks[requestID] = newCallback; - _ieStandaloneIframeTarget.postMessage("ZOTERO_MSG "+JSON.stringify([null, "connectorRequest", + _ieStandaloneIframeTarget.postMessage(JSON.stringify([null, "connectorRequest", [requestID, method, JSON.stringify(data)]]), "http://127.0.0.1:23119/connector/ieHack"); } else { Zotero.debug("Connector: No iframe target; not sending to Standalone"); diff --git a/chrome/content/zotero/xpcom/connector/translate_item.js b/chrome/content/zotero/xpcom/connector/translate_item.js index e47c42694f..b856e62dfe 100644 --- a/chrome/content/zotero/xpcom/connector/translate_item.js +++ b/chrome/content/zotero/xpcom/connector/translate_item.js @@ -32,7 +32,26 @@ Zotero.Translate.ItemSaver = function(libraryID, attachmentMode, forceTagType, d this._uri = document.location.toString(); this._cookie = document.cookie; } + + // Add listener for callbacks + if(!Zotero.Translate.ItemSaver._attachmentCallbackListenerAdded) { + Zotero.Messaging.addMessageListener("attachmentCallback", function(data) { + var id = data[0], + status = data[1]; + var callback = Zotero.Translate.ItemSaver._attachmentCallbacks[id]; + if(callback) { + if(status === false || status === 100) { + delete Zotero.Translate.ItemSaver._attachmentCallbacks[id]; + } + data[1] = 50+data[1]/2; + callback(data[1], data[2]); + } + }); + Zotero.Translate.ItemSaver._attachmentCallbackListenerAdded = true; + } } +Zotero.Translate.ItemSaver._attachmentCallbackListenerAdded = false; +Zotero.Translate.ItemSaver._attachmentCallbacks = {}; Zotero.Translate.ItemSaver.ATTACHMENT_MODE_IGNORE = 0; Zotero.Translate.ItemSaver.ATTACHMENT_MODE_DOWNLOAD = 1; @@ -41,8 +60,16 @@ Zotero.Translate.ItemSaver.ATTACHMENT_MODE_FILE = 2; Zotero.Translate.ItemSaver.prototype = { /** * Saves items to Standalone or the server + * @param items Items in Zotero.Item.toArray() format + * @param {Function} callback A callback to be executed when saving is complete. If saving + * succeeded, this callback will be passed true as the first argument and a list of items + * saved as the second. If saving failed, the callback will be passed false as the first + * argument and an error object as the second + * @param {Function} [attachmentCallback] A callback that receives information about attachment + * save progress. The callback will be called as attachmentCallback(attachment, false, error) + * on failure or attachmentCallback(attachment, progressPercent) periodically during saving. */ - "saveItems":function(items, callback) { + "saveItems":function(items, callback, attachmentCallback) { var me = this; // first try to save items via connector var payload = {"items":items}; @@ -58,30 +85,453 @@ Zotero.Translate.ItemSaver.prototype = { } else if(Zotero.isFx) { callback(false, new Error("Save via Standalone failed with "+status)); } else { - me._saveToServer(items, callback); + me._saveToServer(items, callback, attachmentCallback); } }); }, /** * Saves items to server + * @param items Items in Zotero.Item.toArray() format + * @param {Function} callback A callback to be executed when saving is complete. If saving + * succeeded, this callback will be passed true as the first argument and a list of items + * saved as the second. If saving failed, the callback will be passed false as the first + * argument and an error object as the second + * @param {Function} attachmentCallback A callback that receives information about attachment + * save progress. The callback will be called as attachmentCallback(attachment, false, error) + * on failure or attachmentCallback(attachment, progressPercent) periodically during saving. */ - "_saveToServer":function(items, callback) { - var newItems = []; + "_saveToServer":function(items, callback, attachmentCallback) { + var newItems = [], typedArraysSupported = false; + try { + typedArraysSupported = new Uint8Array(1); + } catch(e) {} for(var i=0, n=items.length; i@,;:\\"\/\[\]?={} ]+\/[^\x00-\x1F\x7F()<>@,;:\\"\/\[\]?={} ]+/.exec(contentTypeHeader); + if(m) contentType = m[0].toLowerCase(); + m = /;\s*charset\s*=\s*("[^"]+"|[^\x00-\x1F\x7F()<>@,;:\\"\/\[\]?={} ]+)/.exec(contentTypeHeader); + if(m) { + charset = m[1]; + if(charset[0] === '"') charset = charset.substring(1, charset.length-1); + } + + if(attachment.mimeType + && attachment.mimeType.toLowerCase() !== contentType.toLowerCase()) { + err = new Error("Attachment MIME type "+contentType+ + " does not match specified type "+attachment.mimeType); + } + } + + attachment.mimeType = contentType; + attachment.linkMode = "imported_url"; + switch(contentType.toLowerCase()) { + case "application/pdf": + attachment.filename = baseName+".pdf"; + break; + case "text/html": + case "application/xhtml+xml": + attachment.filename = baseName+".html"; + break; + default: + attachment.filename = baseName; + } + if(charset) attachment.charset = charset; + headersValidated = true; + } + + // If we didn't validate the headers, cancel the request + if(headersValidated === false && "abort" in xhr) xhr.abort(); + + // Add attachments to attachment payload if there was no error + if(!err) { + uploadAttachments.push(attachment); + } + + // If we have retrieved the headers for all attachments, create items on z.org + // server + if(retrieveHeadersForAttachments === 0) createAttachments(); + + // If there was an error, throw it now + if(err) { + attachmentCallback(attachment, false, err); + throw err; + } + }; + + var xhr = new XMLHttpRequest(); + xhr.open("GET", attachment.url, true); + xhr.responseType = "arraybuffer"; + xhr.onloadend = function() { + if(!checkHeaders()) return; + + attachmentCallback(attachment, 50); + attachment.data = xhr.response; + // If item already created, head to upload + if("key" in attachment) { + me._uploadAttachmentToServer(attachment, attachmentCallback); + } + }; + xhr.onprogress = function(event) { + if(this.readyState < 2 || !checkHeaders()) return; + + if(event.total && attachmentCallback) { + attachmentCallback(attachment, event.loaded/event.total*50); + } + }; + xhr.send(); + + if(attachmentCallback) { + attachmentCallback(attachment, 0); + } + })(attachments[i]); + } + }, + + /** + * Uploads an attachment to the Zotero server + * @param {Object} attachment Attachment object, including + * @param {Function} attachmentCallback A callback that receives information about attachment + * save progress. The callback will be called as attachmentCallback(attachment, false, error) + * on failure or attachmentCallback(attachment, progressPercent) periodically during saving. + */ + "_uploadAttachmentToServer":function(attachment, attachmentCallback) { + var binaryHash = this._md5(new Uint8Array(attachment.data), 0, attachment.data.byteLength), + hash = ""; + for(var i=0; i + Chris G Jones + Shaon Barman + Vivien Nicolas <21@vingtetun.org> + Justin D'Arcangelo + Yury Delendik + Kalervo Kujala + Adil Allawi <@ironymark> + Jakob Miland + Artur Adib + Brendan Dahl + David Quintana + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + */ + "_md5":(function calculateMD5Closure() { + // Don't throw if typed arrays are not supported + try { + var r = new Uint8Array([ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, + 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, + 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, + 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21]); + + var k = new Int32Array([ + -680876936, -389564586, 606105819, -1044525330, -176418897, 1200080426, + -1473231341, -45705983, 1770035416, -1958414417, -42063, -1990404162, + 1804603682, -40341101, -1502002290, 1236535329, -165796510, -1069501632, + 643717713, -373897302, -701558691, 38016083, -660478335, -405537848, + 568446438, -1019803690, -187363961, 1163531501, -1444681467, -51403784, + 1735328473, -1926607734, -378558, -2022574463, 1839030562, -35309556, + -1530992060, 1272893353, -155497632, -1094730640, 681279174, -358537222, + -722521979, 76029189, -640364487, -421815835, 530742520, -995338651, + -198630844, 1126891415, -1416354905, -57434055, 1700485571, -1894986606, + -1051523, -2054922799, 1873313359, -30611744, -1560198380, 1309151649, + -145523070, -1120210379, 718787259, -343485551]); + } catch(e) {}; + + function hash(data, offset, length) { + var h0 = 1732584193, h1 = -271733879, h2 = -1732584194, h3 = 271733878; + // pre-processing + var paddedLength = (length + 72) & ~63; // data + 9 extra bytes + var padded = new Uint8Array(paddedLength); + var i, j, n; + if (offset || length != data.byteLength) { + padded.set(new Uint8Array(data.buffer, offset, length)); + } else { + padded.set(data); + } + i = length; + padded[i++] = 0x80; + n = paddedLength - 8; + while (i < n) + padded[i++] = 0; + padded[i++] = (length << 3) & 0xFF; + padded[i++] = (length >> 5) & 0xFF; + padded[i++] = (length >> 13) & 0xFF; + padded[i++] = (length >> 21) & 0xFF; + padded[i++] = (length >>> 29) & 0xFF; + padded[i++] = 0; + padded[i++] = 0; + padded[i++] = 0; + // chunking + // TODO ArrayBuffer ? + var w = new Int32Array(16); + for (i = 0; i < paddedLength;) { + for (j = 0; j < 16; ++j, i += 4) { + w[j] = (padded[i] | (padded[i + 1] << 8) | + (padded[i + 2] << 16) | (padded[i + 3] << 24)); + } + var a = h0, b = h1, c = h2, d = h3, f, g; + for (j = 0; j < 64; ++j) { + if (j < 16) { + f = (b & c) | ((~b) & d); + g = j; + } else if (j < 32) { + f = (d & b) | ((~d) & c); + g = (5 * j + 1) & 15; + } else if (j < 48) { + f = b ^ c ^ d; + g = (3 * j + 5) & 15; + } else { + f = c ^ (b | (~d)); + g = (7 * j) & 15; + } + var tmp = d, rotateArg = (a + f + k[j] + w[g]) | 0, rotate = r[j]; + d = c; + c = b; + b = (b + ((rotateArg << rotate) | (rotateArg >>> (32 - rotate)))) | 0; + a = tmp; + } + h0 = (h0 + a) | 0; + h1 = (h1 + b) | 0; + h2 = (h2 + c) | 0; + h3 = (h3 + d) | 0; + } + return new Uint8Array([ + h0 & 0xFF, (h0 >> 8) & 0xFF, (h0 >> 16) & 0xFF, (h0 >>> 24) & 0xFF, + h1 & 0xFF, (h1 >> 8) & 0xFF, (h1 >> 16) & 0xFF, (h1 >>> 24) & 0xFF, + h2 & 0xFF, (h2 >> 8) & 0xFF, (h2 >> 16) & 0xFF, (h2 >>> 24) & 0xFF, + h3 & 0xFF, (h3 >> 8) & 0xFF, (h3 >> 16) & 0xFF, (h3 >>> 24) & 0xFF + ]); + } + return hash; + })() }; \ No newline at end of file diff --git a/chrome/content/zotero/xpcom/translation/translate.js b/chrome/content/zotero/xpcom/translation/translate.js index 79c4b17b80..86403f6b63 100644 --- a/chrome/content/zotero/xpcom/translation/translate.js +++ b/chrome/content/zotero/xpcom/translation/translate.js @@ -90,6 +90,7 @@ Zotero.Translate.Sandbox = { const allowedObjects = ["complete", "attachments", "seeAlso", "creators", "tags", "notes"]; + delete item.complete; for(var i in item) { var val = item[i]; var type = typeof val; @@ -99,7 +100,7 @@ Zotero.Translate.Sandbox = { } else if(type === "string") { // trim strings item[i] = val.trim(); - } else if((type === "object" || type === "xml") && allowedObjects.indexOf(i) === -1) { + } else if((type === "object" || type === "xml" || type === "function") && allowedObjects.indexOf(i) === -1) { // convert things that shouldn't be objecst to objects translate._debug("Translate: WARNING: typeof "+i+" is "+type+"; converting to string"); item[i] = val.toString(); @@ -144,6 +145,8 @@ Zotero.Translate.Sandbox = { translate.complete(false, data); throw data; } + }, function(arg1, arg2, arg3) { + translate._attachmentProgress(arg1, arg2, arg3); }); translate._runHandler("itemSaving", item); @@ -985,6 +988,7 @@ Zotero.Translate.Base.prototype = { this._libraryID = libraryID; this._saveAttachments = saveAttachments === undefined || saveAttachments; + this._attachmentsSaving = []; var me = this; if(typeof this.translator[0] === "object") { @@ -1035,30 +1039,6 @@ Zotero.Translate.Base.prototype = { this.decrementAsyncProcesses("Zotero.Translate#translate()"); }, - /** - * Executed when items have been saved (which may happen asynchronously, if in connector) - * - * @param {Boolean} returnValue Whether saving was successful - * @param {Zotero.Item[]|Error} data If returnValue is true, this will be an array of - * Zotero.Item objects. If returnValue is false, this will - * be a string error message. - */ - "itemsSaved":function(returnValue, data) { - if(returnValue) { - // trigger deferred itemDone events - var nItems = data.length; - for(var i=0; i= 128) { + if(val >= 2048) { + array[offset] = ((val >>> 6) | 192); + array[offset+1] = (val & 63) | 128; + offset += 2; + } else { + array[offset] = (val >>> 12) | 224; + array[offset+1] = ((val >>> 6) & 63) | 128; + array[offset+2] = (val & 63) | 128; + offset += 3; + } + } else { + array[offset++] = val; + } + } + }, + + /** + * Gets the byte length of the UTF-8 representation of a given string + * @param {String} string + * @return {Integer} + */ + "getStringByteLength":function(string) { + var length = 0, n = string.length; + for(var i=0; i= 128) { + if(val >= 2048) { + length += 3; + } else { + length += 2; + } + } else { + length += 1; + } + } + return length; } } diff --git a/chrome/skin/default/zotero/progress_arcs.png b/chrome/skin/default/zotero/progress_arcs.png new file mode 100644 index 0000000000000000000000000000000000000000..89c5a14d583b884deb09ea68ce0246a034110b57 GIT binary patch literal 1079 zcmV-71jze|P)4M`&+z zbaP{JX>fEPI4(Ca13zdq000B7Nkl=`H_ln_dQ65FDmaB z#v5<^hyBOECI%T9{1A8{7}tytxM=t(!TgF7m-K<}G(b7%04OT&>&_id^U;Z?ga~Y= z@{8wp$k&id#G4*<+{2?%Dl|NHdBIAw!_IxYY*a1&5(o*mfg84x(s#)dz zQo&DwxqRFKOG4B~7vF1uUquoczY;FuF{3M60Bgd{s0lw(@-c*;6K-T9=aAeTFy8pD z%1e;*Mi^o`8lWWd1t9`_Qt}1xF1yx*URtf-+vP6_2G^wUzxr?8*HGnsBR5uMTx}kB zCS3fG5D{wm8R6+t5F%RRjlVfBjr*MPW4G~So9N*uvTX0YLS897qhtKw;5#iJ4Rn*q#|SW&A0B*{ z%->xEcxzSOZwTWDF7puC5v)kv{lBncck9;Y@oglj9&a+>e~t$IyL76zEB#5jS)DQi z%uw!(xG0WQI9x~h@%^N0Lc{pW^JaiIQqD}a zXoVznX0S1hs67~*Bx%@d_T3d#1lUSLh~=x!|Fij+CRIAOKGukO_!Xf+6~IddzmRPM zf{7Y`CH`*m08QsfqMJ3L0wgDr3mYSxSg|y#X|fV=#c*S#PUcJJ=7cD9!QPZKiM$Yo z7gp?}>=eGe>Y-`zO(LI_$ZPz0ycysRk~{@R8uJq_R<5H-svTVRQmitp;Xdq=_VT53 z?pZ$gb+2v x2_N-he)h@sMdtY@E#^1=Jl+h@WHS9&{R7d0k94|bP*?x}002ovPDHLkV1nLk04)Fj literal 0 HcmV?d00001 diff --git a/chrome/skin/default/zotero/treeitem-attachment-pdf.png b/chrome/skin/default/zotero/treeitem-attachment-pdf.png index 37e6831662acddb003e69b9b2c8798fd6e6b66d8..4d76d4f6f812ee0806570ad89f7c0ddc0ff746a0 100644 GIT binary patch literal 692 zcmV;l0!#ggP)V!|`v3qD(76zY zQ2+n}I!Q!9RCwBalg~?3Q545Nciy{C(HSij!J4$v(x%cT5z7?Xv~OiX)FKy!)IU%` zOEaN(7@g;P_q}&pj9G(D7cN}xx#z?Ae(yOV z8LcC6s}H-tQifrWZwbIx3ZWFUlT#H!>(rUcmoR~?d%JtM*>&99y{kSi04ez}_nFAG zKB~^2`=v@EQDEK#=ComOVelbA5ai8w55{p$ccFmj^?u%UpPBhVX&K-rg)g zHZ?xTd|lv6?e@-Sb#<=n@izoJYVfyIp(`s0a)P0S1%z#{yU%$u)!c-Yu{Zd50j3F# zlRf7H6s|b=gWtYW@Z?$6$UIFJ+PLim^7$BELp|znD@8Z@!F35L%F&xj(c$o44>l}a zgY{yJm6KhVi5ZOP1jQGxU?dmuYIoq*?p%|V3$P@(io)A>2+GP3j)OXK2-Vh#NTo1l zKVeLN#2Ow&)K#wn{xL|tdW{@?3DU&wISZCVhl{hLRI?2~wg=JCj!37`cL%coXP)Eco4!M0@&NzGa~Z=0*D31*u7^DgO;WiUd`98Ut{2!JcHqGNx}c5nrgT~ z00G4EPF5A{f+7O8|2EKLV2BK1;9+BDkefQ2VIBhm+#rAeVqzc>EdTn6VfnYu4D(uA z;bH&*#6pSz<`xzV=9e#m**_*ugo^_N5DTlNIm7$+9~gf9`pLk{$A_Yc`OaO2e-h#h z|9N;A{;DYBG#ns+Sa?@0VdMoemMvTUA7m3U;F!Od;SVq@{ut`xbs<0i;SCd}7cYqN zCO`m@9I)5`KmZXmgz3dgf`$MD5KcoFKYsxmz_w!-n2iqr1Q1R`*w?IQ_~qfo@I5D! zfxD)Gf$2H!@Bs)Q7F@0XMelDLD+bmh$H37HGKl5QJ%&FjN|5O0VuPy(2p|@025?T9 z4pzwc