diff --git a/extension/action_popup.css b/extension/action_popup.css index 554fb45d..e6e5f3dc 100644 --- a/extension/action_popup.css +++ b/extension/action_popup.css @@ -1,69 +1,163 @@ body { width: 24em; /* prevent scroll bars*/ overflow: hidden; margin: 0; } .hidden { display: none !important; } header { background: #1d99f3; padding: 2px; color: #fff; text-align: center; } section { margin: 0 0.5em; } section > header { background: #F0F0F0; color: #757777; margin: 0 -1em -0.5em -1em; } .message { padding: 10px; } .message.with-icon::before { content: ''; height: 48px; display: block; background: center no-repeat; } .message.with-icon.general::before { background-image: url('icons/plasma.svg'); } .message.with-icon.error::before { background-image: url('icons/sad-face.svg'); } @media (prefers-color-scheme: dark) { body { background-color: #232629; color: #eff0f1; } .message.with-icon.error::before { filter: invert(1); } } /* Media controls blacklist */ .mpris-blacklist-info { padding: 0.5em 0; } .mpris-blacklist-info p { padding: 0 0.5em; } .mpris-blacklist-info ul { display: block; padding: 0 0.5em; } .mpris-blacklist-info ul > li { display: block; margin-bottom: 0.5em; } + +/* Itinerary */ +.itinerary-info { + +} +.itinerary-info div.banner { + width: 100%; + height: 100px; + overflow: hidden; +} +.itinerary-info .banner > .blur { + overflow: hidden; + position: absolute; + width: 100%; + height: 100px; + /*transform: scale(2);*/ + filter: blur(10px); + background-size: 100% 100%; + z-index: -1; +} +.itinerary-info .banner > .image { + width: 100%; + height: 100px; + background-repeat: no-repeat; + background-position: center; + background-size: contain; + overflow: auto; + position: relative; +} + +.itinerary-info h2 { + padding: 0 0.5em; + text-align: center; +} + +.itinerary-info p { + padding: 0 0.5em; + /* HACK this voodoo elides too long text */ + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 5; + overflow: hidden; +} + +ul.actions { + display: block; + padding: 0 0.5em; +} +ul.actions > li { + display: block; + margin-bottom: 0.5em; +} +/*ul.actions > li:not(:last-child) { + margin-bottom: 0; +}*/ + +ul.actions > li > a { + display: block; + background: #3daee9; /* Breeze highlight color */ + color: #fcfcfc; /* Breeze highlight text color */ + text-decoration: none; + padding: 0.5em; + border-radius: 0.2em; +} + +ul.details { + list-style: none; + padding-left: 0.5em; +} +ul.details > li { + margin-bottom: 0.5em; +} + +ul.details li.icon::before { + margin-right: 0.5em; +} +ul.details li.icon.dates::before { + content: "📅"; /* "Calendar" Emoji */ +} +ul.details li.icon.location::before { + content: "📍" /* "Location pin" Emoji */ +} +ul.details li.icon.email::before { + content: "📧" /* "EMail" Emoji */ +} +ul.details li.icon.phone::before { + content: "☎️" /* "Phone" Emoji */ +} +ul.details li.icon.ticket::before { + content: "🎫" /* "Ticket" Emoji */ +} +ul.details li.icon.seat::before { + content: "💺" /* "Seat" Emoji */ +} diff --git a/extension/action_popup.html b/extension/action_popup.html index 57d95def..3e38c73b 100644 --- a/extension/action_popup.html +++ b/extension/action_popup.html @@ -1,61 +1,125 @@
I18N
diff --git a/extension/action_popup.js b/extension/action_popup.js index 1711a40d..8c77d6f1 100644 --- a/extension/action_popup.js +++ b/extension/action_popup.js @@ -1,271 +1,571 @@ /* Copyright (C) 2019 Kai Uwe Broulik This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ class TabUtils { // Gets the currently viewed tab static getCurrentTab() { return new Promise((resolve, reject) => { chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { const error = chrome.runtime.lastError; if (error) { return reject(error.message); } const tab = tabs[0]; if (!tab) { return reject("NO_TAB"); } resolve(tab); }); }); } // Gets the URLs of the currently viewed tab including all of its iframes static getCurrentTabFramesUrls() { return new Promise((resolve, reject) => { TabUtils.getCurrentTab().then((tab) => { chrome.tabs.executeScript({ allFrames: true, // so we also catch iframe videos code: `window.location.href`, runAt: "document_start" }, (result) => { const error = chrome.runtime.lastError; if (error) { return reject(error.message); } resolve(result); }); }); }); } }; class MPrisBlocker { getAllowed() { return new Promise((resolve, reject) => { Promise.all([ SettingsUtils.get(), TabUtils.getCurrentTabFramesUrls() ]).then((result) => { const settings = result[0]; const currentUrls = result[1]; const mprisSettings = settings.mpris; if (!mprisSettings.enabled) { return reject("MPRIS_DISABLED"); } if (!currentUrls) { // can this happen? return reject("NO_URLS"); } const origins = currentUrls.map((url) => { try { return new URL(url).origin; } catch (e) { console.warn("Invalid url", url); return ""; } }).filter((origin) => { return !!origin; }); if (origins.length === 0) { return reject("NO_ORIGINS"); } const uniqueOrigins = [...new Set(origins)]; const websiteSettings = mprisSettings.websiteSettings || {}; let response = { origins: {}, mprisSettings }; for (const origin of uniqueOrigins) { let allowed = true; if (typeof MPRIS_WEBSITE_SETTINGS[origin] === "boolean") { allowed = MPRIS_WEBSITE_SETTINGS[origin]; } if (typeof websiteSettings[origin] === "boolean") { allowed = websiteSettings[origin]; } response.origins[origin] = allowed; } resolve(response); }, reject); }); } setAllowed(origin, allowed) { return SettingsUtils.get().then((settings) => { const mprisSettings = settings.mpris; if (!mprisSettings.enabled) { return reject("MPRIS_DISABLED"); } let websiteSettings = mprisSettings.websiteSettings || {}; let implicitAllowed = true; if (typeof MPRIS_WEBSITE_SETTINGS[origin] === "boolean") { implicitAllowed = MPRIS_WEBSITE_SETTINGS[origin]; } if (allowed !== implicitAllowed) { websiteSettings[origin] = allowed; } else { delete websiteSettings[origin]; } mprisSettings.websiteSettings = websiteSettings; return SettingsUtils.set({ mpris: mprisSettings }); }); } }; +class Itinerary { + extract() { + TabUtils.getCurrentTab().then((tab) => { + // Extract currently viewed PDF, e.g. if vieweing boarding pass PDF in internal PDF viewer + if (new URL(tab.url).pathname.toLowerCase().endsWith(".pdf")) { + const reader = new BlobReader(tab.url); + reader.type = BlobReader.READ_TYPE_DATA_URL; + reader.read().then((result) => { + // Limit from ExtractorEngine, plus 30% overhead of being base64 encoded + if (result.length > 4000000 * 1.3) { + return; + } + + this._doExtract("Pdf", result); + }); + return; + } + + // Extract website HTML + chrome.tabs.executeScript({ + allFrames: false, + code: `document.documentElement.outerHTML`, + runAt: "document_end" + }, (result) => { + if (chrome.runtime.lastError) { + return; + } + + result = result[0]; + if (!result) { + return; + } + + this._doExtract("Html", result); + }); + }); + } + + _doExtract(type, data) { + return new Promise((resolve, reject) => { + sendMessage("itinerary", "extract", { + type, + data + }).then((result) => { + if (!result.success) { + // TODO print error? + return; + } + + this.buildUi(result.data); + }); + }); + } + + buildUi(result) { + if (!result || result.length === 0) { + return; + } + + const foundTypes = result.map((item) => { + return item["@type"]; + }); + + // is joined together as a string so I don't always have to expand the Array in the developer console + console.log("Itinerary extractor found types:", foundTypes.join(",").substr(0,100)); + + const filteredResult = result.filter((item) => { + return SUPPORTED_ITINERARY_TYPES.includes(item["@type"]); + }); + + // Likely an overview page, not much practical use + if (filteredResult.length > MAXIMUM_ITINERARY_TYPE_OCCURRENCES) { + return; + } + + // HACK prefer the result with most keys, likely to have more useful data + // Ideally KItinerary filtered out obvious garbage like a Restaurant with just a name and nothing else + filteredResult.sort((a, b) => { + return Object.keys(b).length - Object.keys(a).length; + }); + + const data = filteredResult[0]; + if (!data) { + return; + } + + // There's some content, hide dummy placeholder + document.getElementById("dummy-main").classList.add("hidden"); + + const infoElement = document.querySelector(`.itinerary-info[data-itinerary-context*='${data["@type"]}'`); + infoElement.classList.remove("hidden"); + + // All of this below is pretty ridiculous and should be replaced by a proper HTML templating engine :) + const elements = infoElement.querySelectorAll("[data-prop]"); + for (const element of elements) { + const keys = element.dataset.prop.split(","); + + const values = keys.map((key) => { + const segments = key.split("."); + let depth = 0; + let value = data; + + while (depth < segments.length) { + const key = segments[depth]; + value = value[key]; + ++depth; + if (!value) { + return undefined; + } + } + + return value; + }); + const value = values[0]; + + if (!values.some((value) => { + return !!value; + })) { + element.classList.add("hidden"); + continue; + } + + switch (element.tagName) { + case "IMG": + element.src = value; + break; + default: + const formatter = element.dataset.formatter; + switch (formatter) { + + case "daterange": + // TODO check if there's only one date + const now = new Date(); + const startDate = new Date(values[0]); + const endDate = new Date(values[1]); + + // TODO pretty format when end on same as start, no year if same and current etc etc etc + + const formattedDates = values.map((value) => { + return new Date(value).toLocaleString(navigator.language, { + weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' + }); + }); + + element.innerText = `${formattedDates[0]} to ${formattedDates[1]}`; + + + /*let startDateFormat = { + weekday: "short" + day: "numeric", + month: "short" + }; + let endDateFormat = { + + }; + + if (startDate.getFullYear() !== endDate.getFullYear() + || startDate.getFullYear() !== now.getFullYear() + || endDate.getFullYear() !== now.getFullYear()) { + dateFormat.year = "numeric"; + } + + if (startDate.getHours() !== 0 || startDate.getMinutes() !== 0 || startDate.getSeconds() !== 0 + || endDate.getHours() !== 0 || endDate.getMinutes() !== 0 || endDate.getSeconds !== 0) { + dateFormat.hour = "2-digit"; + dateFormat.minute = "2-digit"; + } + + + // TODO handle if there's only one date + element.innerText = `${formattedDates[0]} to ${formattedDates[1]}`;*/ + break; + + case "place": + const address = value.address; + switch (address["@type"]) { + case "PostalAddress": + // TODO geo url or OSM or whatever + // FIXME + element.innerText = `${address.streetAddress}, ${address.postalCode}, ${address.addressLocality} (${address.addressCountry})`; + break; + } + + break; + case "address": + switch (value["@type"]) { + case "PostalAddress": + // TODO geo url or OSM or whatever + // FIXME + element.innerText = `${value.streetAddress}\n${value.postalCode}, ${value.addressLocality} (${value.addressCountry})`; + break; + } + + break; + + case "phonelink": { + let link = document.createElement("a"); + link.href = "tel:" + value; + link.target = "_blank"; + link.innerText = value; // TODO pretty format phone number + link.addEventListener("click", (e) => { + e.preventDefault(); + alert("Call " + link.href); + }); + // TODO clear element before + element.appendChild(link); + break; + } + + case "emaillink": { + let link = document.createElement("a"); + link.href = "mailto:" + value; + link.target = "_blank"; + link.innerText = value; + element.appendChild(link); + break; + } + + case "background-image": + let backgroundUrl = value; + if (backgroundUrl.startsWith("//")) { + // FIXME resolve properly based on current website URL! + backgroundUrl = "https:" + backgroundUrl; + } + element.style.backgroundImage = `url('${backgroundUrl}')`; + break; + + case "noop": + break; + default: + element.innerText = value; + } + } + } + + const outOfContextElements = infoElement.querySelectorAll("[data-context]:not([data-context=" + data["@type"]); + outOfContextElements.forEach((element) => { + element.classList.add("hidden"); + }); + + const actionsListElement = document.getElementById("itinerary-info-actions"); + + const actions = data.potentialAction || []; + for (const action of actions) { + let listItem = document.createElement("li"); + + let link = document.createElement("a"); + + let text = action.result && action.result.name; + if (!text) { + switch (action["@type"]) { + case "ReserveAction": + switch (data["@type"]) { + case "FoodEstablishment": + case "Restaurant": + // TODO rest + text = "Reserve a Table"; // FIXME i18n + break; + } + + break; + } + } + + link.innerText = text; + + link.href = action.target; + link.target = "_blank"; + + listItem.appendChild(link); + + actionsListElement.appendChild(listItem); + } + + document.querySelectorAll("[data-action]").forEach((element) => { + element.addEventListener("click", (e) => { + e.preventDefault(); + + const action = element.dataset.action; + + switch (action) { + case "add-to-itinerary": + + /*sendMessage("itinerary", "run", { + path: "" + });*/ + + break; + } + }); + }); + } +}; + document.addEventListener("DOMContentLoaded", () => { sendMessage("browserAction", "getStatus").then((status) => { switch (status.portStatus) { case "UNSUPPORTED_OS": document.getElementById("unsupported_os_error").classList.remove("hidden"); break; case "STARTUP_FAILED": document.getElementById("startup_error").classList.remove("hidden"); break; default: document.getElementById("main").classList.remove("hidden"); let errorText = status.portLastErrorMessage; if (errorText === "UNKNOWN") { errorText = chrome.i18n.getMessage("general_error_unknown"); } if (errorText) { document.getElementById("runtime_error_text").innerText = errorText; document.getElementById("runtime_error").classList.remove("hidden"); // There's some content, hide dummy placeholder document.getElementById("dummy-main").classList.add("hidden"); } break; } // HACK so the extension can tell we closed, see "browserAction" "ready" callback in extension.js chrome.runtime.onConnect.addListener((port) => { if (port.name !== "browserActionPort") { return; } // do we need to do something with the port here? }); sendMessage("browserAction", "ready"); }); // MPris blocker checkboxes const blocker = new MPrisBlocker(); blocker.getAllowed().then((result) => { const origins = result.origins; if (Object.entries(origins).length === 0) { // "isEmpty" return; } // To keep media controls setting from always showing up, only show them, if: // - There is actually a player anywhere on this tab // or, since when mpris is disabled, there are never any players // - when media controls are disabled for any origin on this tab new Promise((resolve, reject) => { for (let origin in origins) { if (origins[origin] === false) { return resolve("HAS_BLOCKED"); } } TabUtils.getCurrentTab().then((tab) => { return sendMessage("mpris", "hasTabPlayer", { tabId: tab.id }); }).then((playerIds) => { if (playerIds.length > 0) { return resolve("HAS_PLAYER"); } reject("NO_PLAYER_NO_BLOCKED"); }); }).then(() => { // There's some content, hide dummy placeholder document.getElementById("dummy-main").classList.add("hidden"); let blacklistInfoElement = document.querySelector(".mpris-blacklist-info"); blacklistInfoElement.classList.remove("hidden"); let originsListElement = blacklistInfoElement.querySelector("ul.mpris-blacklist-origins"); for (const origin in origins) { const originAllowed = origins[origin]; let blockListElement = document.createElement("li"); let labelElement = document.createElement("label"); labelElement.innerText = origin; let checkboxElement = document.createElement("input"); checkboxElement.type = "checkbox"; checkboxElement.checked = (originAllowed === true); checkboxElement.addEventListener("click", (e) => { // Let us handle (un)checking the checkbox when setAllowed succeeds e.preventDefault(); const allowed = checkboxElement.checked; blocker.setAllowed(origin, allowed).then(() => { checkboxElement.checked = allowed; }, (err) => { console.warn("Failed to change media controls settings:", err); }); }); labelElement.insertBefore(checkboxElement, labelElement.firstChild); blockListElement.appendChild(labelElement); originsListElement.appendChild(blockListElement); } }, (err) => { console.log("Not showing media controls settings because", err); }); }, (err) => { console.warn("Failed to check for whether media controls are blocked", err); }); + + sendMessage("settings", "getSubsystemStatus").then((status) => { + if (status && status.itinerary && status.itinerary.loaded) { + new Itinerary().extract(); + } + }); }); diff --git a/extension/constants.js b/extension/constants.js index 798d3eda..1541119a 100644 --- a/extension/constants.js +++ b/extension/constants.js @@ -1,54 +1,61 @@ /* Copyright (C) 2017 Kai Uwe Broulik Copyright (C) 2018 David Edmundson This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ DEFAULT_EXTENSION_SETTINGS = { mpris: { enabled: true, websiteSettings: {} }, mprisMediaSessions: { enabled: true }, kdeconnect: { enabled: true }, downloads: { enabled: true, saveOriginUrl: false }, tabsrunner: { enabled: true }, purpose: { enabled: true }, + itinerary: { + enabled: true + }, breezeScrollBars: { // this breaks pages in interesting ways, disable by default enabled: false } }; IS_FIREFOX = (typeof InstallTrigger !== "undefined"); // heh. // NOTE if you change this, make sure to adjust the error message shown in action_popup.html SUPPORTED_PLATFORMS = ["linux", "openbsd", "freebsd"]; // Default MPRIS settings for websites const MPRIS_WEBSITE_SETTINGS = { //"https://www.example.com": false }; + +// Supported schema.org types for itinerary extraction, sorted by preferences +const SUPPORTED_ITINERARY_TYPES = ["Event", "FoodEstablishment", "LodgingBusiness", "FlightReservation", "TrainReservation"]; +const MAXIMUM_ITINERARY_TYPE_OCCURRENCES = 15; diff --git a/extension/extension-itinerary-quick-extractor.js b/extension/extension-itinerary-quick-extractor.js new file mode 100644 index 00000000..559757a2 --- /dev/null +++ b/extension/extension-itinerary-quick-extractor.js @@ -0,0 +1,64 @@ +/* + Copyright (C) 2019 Kai Uwe Broulik + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 3 of + the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +(function() { + + // This is an Object so we can count the number of items ofr any given type + // If any type comes up suspiciously often, we assume it's an overview page + // and of no practical use. + let foundTypes = {}; + + const schemaOrgTags = document.querySelectorAll("[itemtype*='schema.org']"); + + schemaOrgTags.forEach((tag) => { + let type = tag.getAttribute("itemtype"); + // Using https for an URI is wrong but some websites do that... + type = type.replace(/^https?:\/\/s?schema.org\//, ""); + if (!type) { + return; // continue + } + + foundTypes[type] = (foundTypes[type] || 0) + 1; + }); + + const jsonLdScriptTags = document.querySelectorAll("script[type='application/ld+json']"); + jsonLdScriptTags.forEach((tag) => { + try { + let json = JSON.parse(tag.innerText); + if (json) { + if (!Array.isArray(json)) { // turn it into an array so we can use same codepath below + json = [json]; + } + + json.forEach((item) => { + const type = item["@type"]; + if (!type) { + return; // continue + } + + foundTypes[type] = (foundTypes[type] || 0) + 1; + }); + } + } catch (e) { + console.warn("Failed to parse json ld in", tag, e); + return; + } + }); + + return foundTypes; + +})(); diff --git a/extension/extension-itinerary.js b/extension/extension-itinerary.js new file mode 100644 index 00000000..b8cb88bf --- /dev/null +++ b/extension/extension-itinerary.js @@ -0,0 +1,208 @@ +/* + Copyright (C) 2019 Kai Uwe Broulik + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 3 of + the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +const badgeIcons = { + "Event": "📅", // "Calendar" Emoji + "LodgingBusiness": "🏨", // "Hotel" Emoji, looks more like a hospital to me + "FoodEstablishment": "🍽" // "Fork and Knive With Plate" Emoji + // TODO FlightReservation +}; + +const genericTypes = { + "Event": [ + new RegExp(".*Event$"), + "CourseInstance", + "EventSeries", + "Festival" + ], + "LodgingBusiness": [ + "BedAndBreakfast", + "Campground", + "Hostel", + "Hotel", + "Motel", + "Resort" + ], + "FoodEstablishment": [ + "Bakery", + "BarOrPub", + "Brewery", + "CafeOrCoffeeShop", + "Distillery", + "FastFoodRestaurant", + "IceCreamShop", + "Restaurant", + "Winery" + ] +}; + +function generalizeType(type) { + for (const genericType in genericTypes) { + const candidates = genericTypes[genericType]; + + for (const candidate of candidates) { + if (typeof candidate === "string" && type === candidate) { + return genericType; + } + + if (candidate instanceof RegExp && type.match(candidate)) { + return genericType; + } + } + } + + return type; +} + +let itineraryTabCache = {}; + +let itineraryEnabled = undefined; + +SettingsUtils.onChanged().addListener((delta) => { + // Check again next time + if (delta.itinerary) { + itineraryEnabled = undefined; + } +}); + +// This is a basic check for whether there is anything worth extracting +// the actual recursive extraction of structured data happens in the content +// script on demand once the browser action is actually opened +function checkForStructuredData() { + // Check whether itinerary is enabled and loaded and supported + // Only do so once until settings are changed, to avoid continuous getSubsystemStatus queries + new Promise((resolve, reject) => { + if (itineraryEnabled !== undefined) { + return resolve(itineraryEnabled); + } + + sendPortMessageWithReply("settings", "getSubsystemStatus").then((status) => { + const itinerary = status.itinerary; + resolve(itinerary && itinerary.loaded && itinerary.extractorFound); + }); + }).then((enabled) => { + itineraryEnabled = enabled; + + if (!enabled) { + return; + } + + chrome.tabs.query({ + currentWindow: true, + active: true + }, (tabs) => { + if (chrome.runtime.lastError) { + return; + } + + const tab = tabs[0]; + if (!tab) { + return; + } + + // We set the browser action specifically on the tab, so if we checked a tab once + // we don't need to do it again. However, if the tab navigated to a different page, + // we invalidated the cache and will check again. + if (itineraryTabCache[tab.id]) { + return; + } + + chrome.tabs.executeScript(tab.id, { + frameId: 0, // main frame + file: "extension-itinerary-quick-extractor.js" + }, (result) => { + const error = chrome.runtime.lastError; + if (error) { + return; + } + + result = result[0] || {}; + itineraryTabCache[tab.id] = result; + + // Generalize types and add specific occurrences to the general type + Object.keys(result).forEach((type) => { + const genericType = generalizeType(type); + + if (genericType !== type) { + result[genericType] = (result[genericType] || 0) + result[type]; + delete result[type]; + } + }); + + let possibleIcon = ""; + // Should we have a priority order here? + for (const type of SUPPORTED_ITINERARY_TYPES) { + const count = result[type]; + if (!count) { + continue; + } + + // Probably an overview page, no practical use for us + if (count > MAXIMUM_ITINERARY_TYPE_OCCURRENCES) { + console.log(`Found too many (${count}) occurrences of ${type}, skipping`); + // Should we really still carry on after this? + continue; + } + + possibleIcon = badgeIcons[type]; + break; + } + + // We don't need to unset the icon as we set it on a specific tab and navigating + // away to a different page will clear it automatically. + // More importantly, this keeps us from clearing an error icon. + if (!possibleIcon) { + return; + } + + chrome.browserAction.setBadgeText({ + text: possibleIcon, + tabId: tab.id + }); + + chrome.browserAction.setBadgeBackgroundColor({ + color: "#4d4d4d", // Breeze "black" icon color + tabId: tab.id + }); + }); + + }); + }); +} + +chrome.tabs.onActivated.addListener(checkForStructuredData); +chrome.windows.onFocusChanged.addListener(checkForStructuredData); + +chrome.tabs.onRemoved.addListener((tabId) => { + delete itineraryTabCache[tabId]; +}); + +chrome.tabs.onUpdated.addListener((tabId, info, tab) => { + if (info.status === "complete") { + delete itineraryTabCache[tabId]; + + checkForStructuredData(); + } +}); + +addRuntimeCallback("itinerary", "extract", (message) => { + return new Promise((resolve, reject) => { + sendPortMessageWithReply("itinerary", "extract", message).then((reply) => { + resolve(reply); + }); + }); +}); diff --git a/extension/icons/airplane.svg b/extension/icons/airplane.svg new file mode 100644 index 00000000..8e67ebdd --- /dev/null +++ b/extension/icons/airplane.svg @@ -0,0 +1,79 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/extension/manifest.json b/extension/manifest.json index 842038ef..e8b4f85f 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,74 +1,75 @@ { "manifest_version": 2, "name": "Plasma Integration", "short_name": "Plasma", "description": "Provides better integration with the KDE Plasma 5 desktop.", "version": "1.6.1", "default_locale": "en", "author": "Kai Uwe Broulik ", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1Wz6JigIdPBxPJvYrnjuKM3X4YEaUHgyOW2TM1G1Maxook1HO8vLFGhBR7g3jEQ7Yw9PTa6ZAa8J/I9a/1EQz0Ssx2+m3R9SvJfdKSCrCth8nwMpB8V8I5EhiUoW57kM0v9r/18Buem5cBIo0EnSAVCbmNG95R5jtg62P4+LNGEwHk5L7RSblXUN2hhUwXty4A98EXD6T2Pnpu1m8nRF2B1t5AcspSMV5ECnL9x8kT5bup1gJo0FGTz6C+1huDNRaI3OY1YDWyvCVRFXlAhuFERTW6siMdghK++kYM43H7cvJMFYnVVuffD9WNRsUCI9V0SrqYbPre/2nEl+8VUXVwIDAQAB", "icons": { "16": "icons/plasma-16.png", "32": "icons/plasma-32.png", "48": "icons/plasma-48.png", "128": "icons/plasma-128.png" }, "background": { "scripts": [ "constants.js", "utils.js", "extension-utils.js", "extension-kdeconnect.js", "extension-mpris.js", "extension-downloads.js", "extension-tabsrunner.js", "extension-purpose.js", + "extension-itinerary.js", "extension.js" ], "persistent": false }, "browser_action": { "browser_style": true, "default_popup": "action_popup.html" }, "content_scripts": [ { "matches": ["*://*/*"], "js": ["constants.js", "utils.js", "content-utils.js", "content-script.js"], "run_at":"document_start", "all_frames": true, "match_about_blank": true } ], "options_ui": { "page": "options.html", "chrome_style": true }, "permissions": [ "nativeMessaging", "notifications", "storage", "downloads", "tabs", "", "contextMenus" ], "applications": { "gecko": { "id": "plasma-browser-integration@kde.org", "strict_min_version": "50.0" } }, "optional_permissions": [ "webRequest" ] } diff --git a/extension/options.html b/extension/options.html index 541f184f..adb8d520 100644 --- a/extension/options.html +++ b/extension/options.html @@ -1,118 +1,125 @@

I18N

I18N

I18N

I18N

I18N
  • I18N

  • I18N

  • I18N

  • I18N

  • I18N

  • I18N

  • +
  • + +

    I18N

    +

    Itinerary not installed

    +
  • I18N

I18N
I18N

I18N
I18N
I18N

I18N

I18N

I18N

I18N

diff --git a/extension/options.js b/extension/options.js index 33c70704..4beadca2 100644 --- a/extension/options.js +++ b/extension/options.js @@ -1,264 +1,272 @@ /* Copyright (C) 2017 Kai Uwe Broulik Copyright (C) 2018 David Edmundson This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ function tabClicked(tabbar, tabbutton) { tabbar.buttons.forEach(function (button) { var tablink = button.dataset.tabLink var tabtarget = document.querySelector("[data-tab-id=" + tablink + "]"); if (tabbutton == button) { button.classList.add("active"); tabtarget.classList.add("active"); } else { button.classList.remove("active"); tabtarget.classList.remove("active"); } }); } function loadSettings() { SettingsUtils.get().then((items) => { for (let key in items) { if (!items.hasOwnProperty(key)) { continue; } let controls = document.querySelectorAll("[data-extension=" + key + "]"); for (let control of controls) { let settingsKey = control.dataset.settingsKey; if (!settingsKey) { console.warn("Invalid settings key in", control, "cannot load this"); continue; } let value = items[key][settingsKey] if (control.type === "checkbox") { control.checked = !!value; } else { if (value === true) { control.value = "TRUE"; } else if (value === false) { control.value = "FALSE"; } else { control.value = value; } } updateDependencies(control, key, settingsKey); control.addEventListener("change", () => { let saveFailureInfoElement = document.getElementById("save-failure-info"); saveFailureInfoElement.classList.add("hidden"); updateDependencies(control, key, settingsKey); saveSettings((error) => { if (error) { saveFailureInfoElement.classList.remove("hidden"); let saveMessageElement = document.getElementById("save-message"); try { saveMessageElement.innerText = chrome.i18n.getMessage("options_save_failed"); } catch (e) { // When the extension is reloaded, any call to extension APIs throws, make sure we show at least some form of error saveMessageElement.innerText = "Saving settings failed (" + (error || e) + ")"; } return; } }); }); } } }); } function saveSettings(cb) { var settings = {}; let controls = document.querySelectorAll("[data-extension]"); for (let control of controls) { let extension = control.dataset.extension; if (!DEFAULT_EXTENSION_SETTINGS.hasOwnProperty(extension)) { console.warn("Cannot save settings for extension", extension, "which isn't in DEFAULT_EXTENSION_SETTINGS"); continue; } let settingsKey = control.dataset.settingsKey; if (!settingsKey) { console.warn("Invalid settings key in", control, "cannot save this"); continue; } if (!settings[extension]) { settings[extension] = {}; } if (!DEFAULT_EXTENSION_SETTINGS[extension].hasOwnProperty(settingsKey)) { console.warn("Cannot save settings key", settingsKey, "in extension", extension, "which isn't in DEFAULT_EXTENSION_SETTINGS"); continue; } if (control.type === "checkbox") { settings[extension][settingsKey] = control.checked; } else { let value = control.value; if (value === "TRUE") { value = true; } else if (value === "FALSE") { value = false; } settings[extension][settingsKey] = value; } } SettingsUtils.set(settings).then(() => { cb(); }, (err) => { cb(err); }); } function updateDependencies(control, extension, settingsKey) { // Update all depending controls let value = control.type === "checkbox" ? control.checked : control.value; if (value === true) { value = "TRUE"; } else if (value === false) { value = "FALSE"; } let dependencies = document.querySelectorAll("[data-depends-extension=" + extension + "][data-depends-settings-key=" + settingsKey + "]"); for (let dependency of dependencies) { dependency.disabled = (value != dependency.dataset.dependsSettingsValue); } } document.addEventListener("DOMContentLoaded", function () { // poor man's tab widget :) document.querySelectorAll(".tabbar").forEach(function (tabbar) { tabbar.buttons = []; tabbar.querySelectorAll("[data-tab-link]").forEach(function (button) { var tablink = button.dataset.tabLink var tabtarget = document.querySelector("[data-tab-id=" + tablink + "]"); button.addEventListener("click", function (event) { tabClicked(tabbar, button); event.preventDefault(); }); tabbar.buttons.push(button); // start with the one tab page that is active if (tabtarget.classList.contains("active")) { tabClicked(tabbar, button); } }); }); if (IS_FIREFOX) { document.querySelectorAll("[data-not-show-in=firefox]").forEach(function (item) { item.style.display = "none"; }); } // check whether the platform is supported before loading and activating settings chrome.runtime.getPlatformInfo(function (info) { if (!SUPPORTED_PLATFORMS.includes(info.os)) { document.body.classList.add("os-not-supported"); return; } loadSettings(); // When getSubsystemStatus fails we assume it's an old host without any of the new features // for which we added the requires-extension attributes. Disable all of them initially // and then have the supported ones enabled below. document.querySelectorAll("[data-requires-extension]").forEach((item) => { item.classList.add("not-supported", "by-host"); }); sendMessage("settings", "getSubsystemStatus").then((status) => { document.querySelectorAll("[data-requires-extension]").forEach((item) => { let requiresExtension = item.dataset.requiresExtension; if (requiresExtension && !status.hasOwnProperty(requiresExtension)) { console.log("Extension", requiresExtension, "is not supported by this version of the host"); return; // continue } let requiresMinimumVersion = Number(item.dataset.requiresExtensionVersionMinimum); if (requiresMinimumVersion) { let runningVersion = status[requiresExtension].version; if (runningVersion < requiresMinimumVersion) { console.log("Extension", requiresExtension, "of version", requiresMinimumVersion, "is required but only", runningVersion, "is present in the host"); return; // continue } } item.classList.remove("not-supported", "by-host"); }); + + // If itinerary is supported in the host but the extractor is not installed, show a hint + const itinerary = status.itinerary; + if (itinerary && !itinerary.extractorFound) { + console.log("Itinerary extractor not found"); + document.getElementById("itinerary-not-installed-info").classList.remove("not-supported"); + } + }).catch((e) => { // The host is most likely not working correctly // If we run this against an older host which doesn't support message replies // this handler is never entered, so we really encountered an error just now! console.warn("Failed to determine subsystem status", e); document.body.classList.add("startup-failure"); }); Promise.all([ sendMessage("settings", "getVersion"), chrome.runtime.getManifest() ]).then((results) => { const versionInfo = results[0]; const manifest = results[1]; document.getElementById("version-info-host").innerText = chrome.i18n.getMessage("options_about_host_version", versionInfo.host); document.getElementById("version-info-extension").innerText = chrome.i18n.getMessage("options_about_extension_version", manifest.version); document.getElementById("version-info").classList.remove("not-supported"); }); }); document.getElementById("open-krunner-settings").addEventListener("click", function (event) { sendMessage("settings", "openKRunnerSettings"); event.preventDefault(); }); // Make translators credit behave like the one in KAboutData var translatorsAboutData = ""; var translators = chrome.i18n.getMessage("options_about_translators"); if (translators && translators !== "Your names") { translatorsAboutData = chrome.i18n.getMessage("options_about_translated_by", translators) } var translatorsAboutDataItem = document.getElementById("translators-aboutdata"); if (translatorsAboutData) { translatorsAboutDataItem.innerText = translatorsAboutData; } else { translatorsAboutDataItem.style.display = "none"; } }); diff --git a/extension/utils.js b/extension/utils.js index 20b2d49d..fa90438c 100644 --- a/extension/utils.js +++ b/extension/utils.js @@ -1,56 +1,117 @@ /* Copyright (C) 2019 Kai Uwe Broulik This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ class SettingsUtils { static storage() { return (IS_FIREFOX ? chrome.storage.local : chrome.storage.sync); } static get() { return new Promise((resolve, reject) => { SettingsUtils.storage().get(DEFAULT_EXTENSION_SETTINGS, (items) => { const error = chrome.runtime.lastError; if (error) { return reject(error); } resolve(items); }); }); } static set(settings) { return new Promise((resolve, reject) => { try { SettingsUtils.storage().set(settings, () => { const error = chrome.runtime.lastError; if (error) { return reject(error); } resolve(); }); } catch (e) { reject(e); } }); } static onChanged() { return chrome.storage.onChanged; } } + +class BlobReader { + constructor(url) { + this._url = url; + this._timeout = -1; + this._type = BlobReader.READ_TYPE_TEXT; + } + + // oof... + static get READ_TYPE_ARRAY_BUFFER() { return "ArrayBuffer"; } + static get READ_TYPE_BINARY_STRING() { return "BinaryString"; } + static get READ_TYPE_DATA_URL() { return "DataURL"; } + static get READ_TYPE_TEXT() { return "Text"; } + + get timeout() { + return this._timeout; + } + set timeout(timeout) { + this._timeout = timeout; + } + + get type() { + return this._type; + } + set type(type) { + this._type = type; + } + + read() { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.onreadystatechange = () => { + if (xhr.readyState != 4) { + return; + } + + if (!xhr.response) { // TODO check status code, too? + return reject("NO_RESPONSE"); + } + + const reader = new FileReader(); + reader.onloadend = () => { + if (reader.error) { + return reject(reader.error); + } + + resolve(reader.result); + }; + + reader["readAs" + this._type](xhr.response); + }; + + xhr.open("GET", this._url); + xhr.responseType = "blob"; + if (this._timeout > -1) { + xhr.timeout = this._timeout; + } + xhr.send(); + }); + } +}; diff --git a/host/CMakeLists.txt b/host/CMakeLists.txt index fbe4345c..00ed353c 100644 --- a/host/CMakeLists.txt +++ b/host/CMakeLists.txt @@ -1,37 +1,40 @@ add_definitions(-DTRANSLATION_DOMAIN=\"plasma-browser-integration-host\") configure_file(config-host.h.cmake ${CMAKE_CURRENT_BINARY_DIR}/config-host.h) set(HOST_SOURCES main.cpp connection.cpp pluginmanager.cpp settings.cpp mprisplugin.cpp abstractbrowserplugin.cpp kdeconnectplugin.cpp downloadplugin.cpp downloadjob.cpp tabsrunnerplugin.cpp purposeplugin.cpp + itineraryplugin.cpp + itineraryextractorjob.cpp ) qt5_add_dbus_adaptor(HOST_SOURCES ../dbus/org.kde.plasma.browser_integration.TabsRunner.xml tabsrunnerplugin.h TabsRunnerPlugin) qt5_add_dbus_adaptor(HOST_SOURCES ../dbus/org.kde.plasma.browser_integration.Settings.xml settings.h Settings) qt5_add_dbus_adaptor(HOST_SOURCES ../dbus/org.mpris.MediaPlayer2.xml mprisplugin.h MPrisPlugin mprisroot MPrisRoot) qt5_add_dbus_adaptor(HOST_SOURCES ../dbus/org.mpris.MediaPlayer2.Player.xml mprisplugin.h MPrisPlugin mprisplayer MPrisPlayer) add_executable(plasma-browser-integration-host ${HOST_SOURCES}) target_link_libraries( plasma-browser-integration-host Qt5::DBus Qt5::Gui Qt5::Widgets KF5::Crash KF5::I18n KF5::KIOCore KF5::PurposeWidgets + KF5::Notifications KF5::FileMetaData ) install(TARGETS plasma-browser-integration-host ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) diff --git a/host/config-host.h.cmake b/host/config-host.h.cmake index 91769e3d..336f706f 100644 --- a/host/config-host.h.cmake +++ b/host/config-host.h.cmake @@ -1 +1,3 @@ #define HOST_VERSION_STRING "${PROJECT_VERSION}" + +#define CMAKE_INSTALL_FULL_LIBEXECDIR_KF5 "${CMAKE_INSTALL_FULL_LIBEXECDIR_KF5}" diff --git a/host/downloadjob.cpp b/host/downloadjob.cpp index e4982a8f..398505e0 100644 --- a/host/downloadjob.cpp +++ b/host/downloadjob.cpp @@ -1,278 +1,289 @@ /* Copyright (C) 2017 by Kai Uwe Broulik Copyright (C) 2017 by David Edmundson 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. */ #include "downloadjob.h" #include "settings.h" #include #include +#include #include #include #include DownloadJob::DownloadJob(int id) : KJob() , m_id(id) { // the thing with "canResume" in chrome downloads is that it just means // "this download can be resumed right now because it is paused", // it's not a general thing. I think we can always pause/resume downloads // unless they're canceled/interrupted at which point we don't have a DownloadJob // anymore anyway setCapabilities(Killable | Suspendable); // TODO When suspending on Firefox the download job goes away for some reason?! // Until I have the virtue to figure that out just disallow suspending downloads on Firefox :) if (Settings::self().environment() == Settings::Environment::Firefox) { setCapabilities(Killable); } } void DownloadJob::start() { QMetaObject::invokeMethod(this, "doStart", Qt::QueuedConnection); } void DownloadJob::doStart() { } bool DownloadJob::doKill() { emit killRequested(); // TODO what if the user kills us from notification area while the // "Save As" prompt is still open? return true; } bool DownloadJob::doSuspend() { emit suspendRequested(); return true; } bool DownloadJob::doResume() { emit resumeRequested(); return true; } +QString DownloadJob::fileName() const +{ + return m_fileName; +} + +QString DownloadJob::mimeType() const +{ + return m_mimeType; +} + void DownloadJob::update(const QJsonObject &payload) { auto end = payload.constEnd(); bool descriptionDirty = false; auto it = payload.constFind(QStringLiteral("url")); if (it != end) { m_url = QUrl(it->toString()); descriptionDirty = true; // TODO only if actually changed } it = payload.constFind(QStringLiteral("finalUrl")); if (it != end) { m_finalUrl = QUrl(it->toString()); descriptionDirty = true; } it = payload.constFind(QStringLiteral("filename")); if (it != end) { m_fileName = it->toString(); const QUrl destination = QUrl::fromLocalFile(it->toString()); setProperty("destUrl", destination.toString(QUrl::RemoveFilename | QUrl::StripTrailingSlash)); m_destination = destination; descriptionDirty = true; } it = payload.constFind(QStringLiteral("mime")); if (it != end) { m_mimeType = it->toString(); } it = payload.constFind(QStringLiteral("incognito")); if (it != end) { m_incognito = it->toBool(); } it = payload.constFind(QStringLiteral("totalBytes")); if (it != end) { const qlonglong totalAmount = it->toDouble(); if (totalAmount > -1) { setTotalAmount(Bytes, totalAmount); } } it = payload.constFind(QStringLiteral("bytesReceived")); if (it != end) { setProcessedAmount(Bytes, it->toDouble()); } setTotalAmount(Files, 1); it = payload.constFind(QStringLiteral("paused")); if (it != end) { const bool paused = it->toBool(); if (paused) { suspend(); } else { resume(); } } it = payload.constFind(QStringLiteral("estimatedEndTime")); if (it != end) { qulonglong speed = 0; // now calculate the speed from estimated end time and total size // funny how chrome only gives us a time whereas KJob operates on speed // and calculates the time this way :) const QDateTime endTime = QDateTime::fromString(it->toString(), Qt::ISODate); if (endTime.isValid()) { const QDateTime now = QDateTime::currentDateTimeUtc(); qulonglong remainingBytes = totalAmount(Bytes) - processedAmount(Bytes); quint64 remainingTime = now.secsTo(endTime); if (remainingTime > 0) { speed = remainingBytes / remainingTime; } } emitSpeed(speed); } if (descriptionDirty) { updateDescription(); } const QString error = payload.value(QStringLiteral("error")).toString(); if (!error.isEmpty()) { if (error == QLatin1String("USER_CANCELED") || error == QLatin1String("USER_SHUTDOWN")) { setError(KIO::ERR_USER_CANCELED); // will keep Notification applet from showing a "finished"/error message emitResult(); return; } // value is a QVariant so we can be lazy and support both KIO errors and custom test // if QVariant is an int: use that as KIO error // if QVariant is a QString: set UserError and message static const QHash errors { // for a list of these error codes *and their meaning* instead of looking at browser // extension docs, check out Chromium's source code: download_interrupt_reason_values.h {QStringLiteral("FILE_ACCESS_DENIED"), i18n("Access denied.")}, // KIO::ERR_ACCESS_DENIED {QStringLiteral("FILE_NO_SPACE"), i18n("Insufficient free space.")}, // KIO::ERR_DISK_FULL {QStringLiteral("FILE_NAME_TOO_LONG"), i18n("The file name you have chosen is too long.")}, {QStringLiteral("FILE_TOO_LARGE"), i18n("The file is too large to be downloaded.")}, // haha {QStringLiteral("FILE_VIRUS_INFECTED"), i18n("The file possibly contains malicious contents.")}, {QStringLiteral("FILE_TRANSIENT_ERROR"), i18n("A temporary error has occurred. Please try again later.")}, {QStringLiteral("NETWORK_FAILED"), i18n("A network error has occurred.")}, {QStringLiteral("NETWORK_TIMEOUT"), i18n("The network operation timed out.")}, // TODO something less geeky {QStringLiteral("NETWORK_DISCONNECTED"), i18n("The network connection has been lost.")}, {QStringLiteral("NETWORK_SERVER_DOWN"), i18n("The server is no longer reachable.")}, {QStringLiteral("SERVER_FAILED"), i18n("A server error has occurred.")}, // chromium code says "internal use" and this is really not something the user should see // SERVER_NO_RANGE" // SERVER_PRECONDITION {QStringLiteral("SERVER_BAD_CONTENT"), i18n("The server does not have the requested data.")}, {QStringLiteral("CRASH"), i18n("The browser application closed unexpectedly.")} }; const QString &errorValue = errors.value(error); if (errorValue.isEmpty()) { // unknown error setError(KIO::ERR_UNKNOWN); setErrorText(i18n("An unknown error occurred while downloading.")); emitResult(); return; } // KIO::Error doesn't have a UserDefined one, let's just use magic numbers then // TODO at least set the KIO::Errors that we do have setError(1000); setErrorText(errorValue); emitResult(); return; } it = payload.constFind(QStringLiteral("state")); if (it != end) { const QString state = it->toString(); // We ignore "interrupted" state and only cancel if we get supplied an "error" if (state == QLatin1String("complete")) { setError(KJob::NoError); setProcessedAmount(KJob::Files, 1); // Write origin url into extended file attributes saveOriginUrl(); emitResult(); return; } } } void DownloadJob::updateDescription() { description(this, i18nc("Job heading, like 'Copying'", "Downloading"), qMakePair(i18nc("The URL being downloaded", "Source"), (m_finalUrl.isValid() ? m_finalUrl : m_url).toDisplayString()), qMakePair(i18nc("The location being downloaded to", "Destination"), m_destination.toLocalFile()) ); } void DownloadJob::saveOriginUrl() { if (m_incognito // Blob URLs are dynamically created through JavaScript and cannot be accessed from the outside || m_finalUrl.scheme() == QLatin1String("blob")) { return; } const QJsonObject settings = Settings::self().settingsForPlugin(QStringLiteral("downloads")); const bool saveOriginUrl = settings.value(QStringLiteral("saveOriginUrl")).toBool(); if (!saveOriginUrl) { return; } KFileMetaData::UserMetaData md(m_fileName); QUrl url = m_finalUrl; url.setPassword(QString()); md.setOriginUrl(url); } diff --git a/host/downloadjob.h b/host/downloadjob.h index 957b34e3..ed7ee677 100644 --- a/host/downloadjob.h +++ b/host/downloadjob.h @@ -1,79 +1,82 @@ /* Copyright (C) 2017 by Kai Uwe Broulik Copyright (C) 2017 by David Edmundson 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. */ #pragma once #include #include class DownloadJob : public KJob { Q_OBJECT public: DownloadJob(int id); enum class State { None, InProgress, Interrupted, Complete }; void start() override; void update(const QJsonObject &payload); + QString fileName() const; + QString mimeType() const; + Q_SIGNALS: void killRequested(); void suspendRequested(); void resumeRequested(); private Q_SLOTS: void doStart(); protected: bool doKill() override; bool doSuspend() override; bool doResume() override; private: void updateDescription(); void saveOriginUrl(); int m_id = -1; QUrl m_url; QUrl m_finalUrl; QUrl m_destination; QString m_fileName; QString m_mimeType; // In doubt, assume incognito bool m_incognito = true; }; diff --git a/host/downloadplugin.cpp b/host/downloadplugin.cpp index a4e28b00..5746442c 100644 --- a/host/downloadplugin.cpp +++ b/host/downloadplugin.cpp @@ -1,116 +1,169 @@ /* Copyright (C) 2017 by Kai Uwe Broulik Copyright (C) 2017 by David Edmundson 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. */ #include "downloadplugin.h" #include "connection.h" #include "downloadjob.h" #include #include +#include +#include + +#include + +#include "itineraryextractorjob.h" + DownloadPlugin::DownloadPlugin(QObject* parent) : AbstractBrowserPlugin(QStringLiteral("downloads"), 2, parent) { } bool DownloadPlugin::onLoad() { // Have extension tell us about all the downloads sendData(QStringLiteral("createAll")); return true; } bool DownloadPlugin::onUnload() { for (auto it = m_jobs.constBegin(), end = m_jobs.constEnd(); it != end; ++it) { it.value()->deleteLater(); // kill() would abort the download } return true; } void DownloadPlugin::handleData(const QString& event, const QJsonObject& payload) { const QJsonObject &download = payload.value(QStringLiteral("download")).toObject(); const int id = download.value(QStringLiteral("id")).toInt(-1); if (id < 0) { qWarning() << "Cannot update download with invalid id" << id; return; } if (event == QLatin1String("created")) { // If we get a created event for an already existing job, update it instead auto *job = m_jobs.value(id); if (job) { job->update(download); return; } job = new DownloadJob(id); // first register and then update, otherwise we miss the initial population.. KIO::getJobTracker()->registerJob(job); job->update(download); m_jobs.insert(id, job); connect(job, &DownloadJob::killRequested, this, [this, id] { sendData(QStringLiteral("cancel"), { {QStringLiteral("downloadId"), id} }); }); connect(job, &DownloadJob::suspendRequested, this, [this, id] { sendData(QStringLiteral("suspend"), { {QStringLiteral("downloadId"), id} }); }); connect(job, &DownloadJob::resumeRequested, this, [this, id] { sendData(QStringLiteral("resume"), { {QStringLiteral("downloadId"), id} }); }); QObject::connect(job, &QObject::destroyed, this, [this, id] { m_jobs.remove(id); }); - job->start(); + QObject::connect(job, &KJob::result, this, [this, job] { + if (job->error()) { + return; + } - QObject::connect(job, &KJob::finished, this, [this, job, id] { + // FIXME check if enabled in settings + if (job->mimeType() == QLatin1String("application/pdf")) { + extractItinerary(job->fileName()); + } }); + job->start(); + } else if (event == QLatin1String("update")) { auto *job = m_jobs.value(id); if (!job) { debug() << "Failed to find download to update with id" << id; return; } job->update(download); } } +void DownloadPlugin::extractItinerary(const QString &fileName) +{ + auto *job = new ItineraryExtractorJob(fileName); + job->setInputType(ItineraryExtractorJob::InputType::Pdf); + job->start(); + + connect(job, &KJob::result, /*this*/ [job, fileName] { + if (job->error()) { + return; + } + + const QJsonArray data = job->extractedData(); + if (data.isEmpty()) { + return; + } + + // HACK some nicer notification + const QString type = data.first().toObject().value(QStringLiteral("@type")).toString(); + + QString message = i18n("Would you like to add this file to KDE Itinerary?"); + QString iconName = QStringLiteral("map-globe"); + if (type == QLatin1String("FlightReservation")) { + message = i18n("Would you like to add this boarding pass to KDE Itinerary?"); + iconName = QStringLiteral("document-send"); + } + + KNotification *noti = KNotification::event(KNotification::Notification, + i18n("Add to Itinerary"), + message, + iconName); + noti->setHint(QStringLiteral("desktop-entry"), QStringLiteral("org.kde.itinerary")); + noti->setActions({i18n("Add to Itinerary")}); + connect(noti, &KNotification::action1Activated, [fileName] { + QProcess::startDetached(QStringLiteral("itinerary"), {fileName}); + }); + noti->sendEvent(); + }); +} diff --git a/host/downloadplugin.h b/host/downloadplugin.h index e07e8046..f1cbc2a4 100644 --- a/host/downloadplugin.h +++ b/host/downloadplugin.h @@ -1,42 +1,44 @@ /* Copyright (C) 2017 by Kai Uwe Broulik Copyright (C) 2017 by David Edmundson 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. */ #pragma once #include "abstractbrowserplugin.h" #include #include "downloadjob.h" class DownloadPlugin : public AbstractBrowserPlugin { Q_OBJECT public: explicit DownloadPlugin(QObject *parent); bool onLoad() override; bool onUnload() override; using AbstractBrowserPlugin::handleData; void handleData(const QString &event, const QJsonObject &data) override; private: + void extractItinerary(const QString &fileName); + QHash m_jobs; }; diff --git a/host/itineraryextractorjob.cpp b/host/itineraryextractorjob.cpp new file mode 100644 index 00000000..867c8722 --- /dev/null +++ b/host/itineraryextractorjob.cpp @@ -0,0 +1,139 @@ +/* + Copyright (C) 2019 by Kai Uwe Broulik + + 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. +*/ + +#include "itineraryextractorjob.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +ItineraryExtractorJob::ItineraryExtractorJob(const QByteArray &inputData, QObject *parent) + : KJob(parent) + , m_inputData(inputData) +{ + +} + +ItineraryExtractorJob::ItineraryExtractorJob(const QString &fileName, QObject *parent) + : KJob(parent) + , m_fileName(fileName) +{ + +} + +ItineraryExtractorJob::~ItineraryExtractorJob() = default; + +bool ItineraryExtractorJob::isSupported() +{ + QFileInfo fi(extractorPath()); + return fi.exists() && fi.isExecutable(); +} + +QString ItineraryExtractorJob::extractorPath() +{ + return QFile::decodeName(CMAKE_INSTALL_FULL_LIBEXECDIR_KF5 "/kitinerary-extractor"); +} + +ItineraryExtractorJob::InputType ItineraryExtractorJob::inputType() const +{ + return m_inputType; +} + +void ItineraryExtractorJob::setInputType(ItineraryExtractorJob::InputType inputType) +{ + m_inputType = inputType; +} + +QJsonArray ItineraryExtractorJob::extractedData() const +{ + return m_extractedData; +} + +void ItineraryExtractorJob::start() +{ + QMetaObject::invokeMethod(this, &ItineraryExtractorJob::doStart, Qt::QueuedConnection); +} + +void ItineraryExtractorJob::doStart() +{ + if (m_inputData.isEmpty() && m_fileName.isEmpty()) { + setError(KIO::ERR_UNKNOWN); // TODO proper error code + emitResult(); + return; + } + + QProcess *process = new QProcess(this); + + connect(process, &QProcess::started, this, [this, process] { + if (m_inputData.isEmpty()) { + return; + } + + process->write(m_inputData); + process->closeWriteChannel(); + }); + + connect(process, QOverload::of(&QProcess::finished), this, [this, process](int exitCode, QProcess::ExitStatus exitStatus) { + process->deleteLater(); + + if (exitCode != 0 || exitStatus != QProcess::NormalExit) { + qWarning() << "Failed to extract itinerary information"; // TODO print error + setError(KIO::ERR_UNKNOWN); // TODO proper error code + emitResult(); + return; + } + + const QByteArray output = process->readAllStandardOutput(); + const QJsonArray itineraryInfo = QJsonDocument::fromJson(output).array(); + + m_extractedData = QJsonDocument::fromJson(output).array(); + emitResult(); + }); + + QStringList args; + if (!m_fileName.isEmpty()) { + args << m_fileName; + } + + if (m_inputType != InputType::Any) { + const QMetaEnum me = QMetaEnum::fromType(); + + const QString key = QString::fromUtf8(me.key(static_cast(m_inputType))); + if (key.isEmpty()) { + setError(KIO::ERR_UNKNOWN); // TODO proper error + emitResult(); + return; + } + + args << QLatin1String("-t") << key; + } + + process->start(extractorPath(), args); +} diff --git a/host/downloadjob.h b/host/itineraryextractorjob.h similarity index 61% copy from host/downloadjob.h copy to host/itineraryextractorjob.h index 957b34e3..62c3c27c 100644 --- a/host/downloadjob.h +++ b/host/itineraryextractorjob.h @@ -1,79 +1,70 @@ /* - Copyright (C) 2017 by Kai Uwe Broulik - Copyright (C) 2017 by David Edmundson + Copyright (C) 2019 by Kai Uwe Broulik 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. */ #pragma once #include -#include +#include +#include +#include -class DownloadJob : public KJob +class ItineraryExtractorJob : public KJob { Q_OBJECT public: - DownloadJob(int id); - - enum class State { - None, - InProgress, - Interrupted, - Complete + ItineraryExtractorJob(const QByteArray &inputData, QObject *parent = nullptr); + ItineraryExtractorJob(const QString &fileName, QObject *parent = nullptr); + ~ItineraryExtractorJob() override; + + enum class InputType { + Any, + Email, + Pdf, + PkPass, + ICal, + Html }; + Q_ENUM(InputType) - void start() override; + static bool isSupported(); - void update(const QJsonObject &payload); + InputType inputType() const; + void setInputType(InputType inputType); -Q_SIGNALS: - void killRequested(); - void suspendRequested(); - void resumeRequested(); + QJsonArray extractedData() const; + + void start() override; private Q_SLOTS: void doStart(); -protected: - bool doKill() override; - bool doSuspend() override; - bool doResume() override; - private: - void updateDescription(); - void saveOriginUrl(); - - int m_id = -1; - - QUrl m_url; - QUrl m_finalUrl; - - QUrl m_destination; + static QString extractorPath(); + InputType m_inputType = InputType::Any; + QByteArray m_inputData; QString m_fileName; - QString m_mimeType; - - // In doubt, assume incognito - bool m_incognito = true; - + QJsonArray m_extractedData; }; diff --git a/host/itineraryplugin.cpp b/host/itineraryplugin.cpp new file mode 100644 index 00000000..9338f046 --- /dev/null +++ b/host/itineraryplugin.cpp @@ -0,0 +1,109 @@ +/* + Copyright (C) 2019 by Kai Uwe Broulik + + 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. +*/ + +#include "itineraryplugin.h" + +#include +#include + +#include "itineraryextractorjob.h" + +ItineraryPlugin::ItineraryPlugin(QObject *parent) + : AbstractBrowserPlugin(QStringLiteral("itinerary"), 1, parent) + , m_supported(ItineraryExtractorJob::isSupported()) +{ + +} + +ItineraryPlugin::~ItineraryPlugin() +{ + +} + +QJsonObject ItineraryPlugin::status() const +{ + return { + {QStringLiteral("extractorFound"), m_supported} + }; +} + +bool ItineraryPlugin::onLoad() +{ + // Check again on load, so reloading the plugin updates + m_supported = ItineraryExtractorJob::isSupported(); + return m_supported; +} + +bool ItineraryPlugin::onUnload() +{ + return true; +} + +QJsonObject ItineraryPlugin::handleData(int serial, const QString &event, const QJsonObject &data) +{ + if (event == QLatin1String("extract")) { + + const QString dataString = data.value(QStringLiteral("data")).toString(); + + QByteArray inputData; + if (dataString.startsWith(QLatin1String("data:"))) { + const int b64start = dataString.indexOf(QLatin1Char(',')); + QByteArray b64 = dataString.rightRef(dataString.size() - b64start - 1).toLatin1(); + inputData = QByteArray::fromBase64(b64); + } else { + inputData = dataString.toUtf8(); + } + + auto *job = new ItineraryExtractorJob(inputData); + + const QString type = data.value(QStringLiteral("type")).toString(); + + const QMetaEnum me = QMetaEnum::fromType(); + const auto inputType = static_cast(me.keyToValue(qUtf8Printable(type))); + job->setInputType(inputType); + + connect(job, &KJob::result, this, [this, job, serial] { + if (job->error()) { + sendReply(serial, { + {QStringLiteral("success"), false}, + {QStringLiteral("errorCode"), job->error()}, + {QStringLiteral("errorMessage"), job->errorText()} + }); + return; + } + + sendReply(serial, { + {QStringLiteral("success"), true}, + {QStringLiteral("data"), job->extractedData()} + }); + }); + + job->start(); + + } else if (event == QLatin1String("run")) { + + // TODO open file in Itinerary + + } + + return {}; +} diff --git a/host/downloadplugin.h b/host/itineraryplugin.h similarity index 76% copy from host/downloadplugin.h copy to host/itineraryplugin.h index e07e8046..0908fd82 100644 --- a/host/downloadplugin.h +++ b/host/itineraryplugin.h @@ -1,42 +1,46 @@ /* - Copyright (C) 2017 by Kai Uwe Broulik - Copyright (C) 2017 by David Edmundson + Copyright (C) 2019 by Kai Uwe Broulik 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. */ #pragma once #include "abstractbrowserplugin.h" -#include -#include "downloadjob.h" - -class DownloadPlugin : public AbstractBrowserPlugin +class ItineraryPlugin : public AbstractBrowserPlugin { Q_OBJECT + public: - explicit DownloadPlugin(QObject *parent); + explicit ItineraryPlugin(QObject *parent); + ~ItineraryPlugin() override; + + QJsonObject status() const override; + bool onLoad() override; bool onUnload() override; + using AbstractBrowserPlugin::handleData; - void handleData(const QString &event, const QJsonObject &data) override; + QJsonObject handleData(int serial, const QString &event, const QJsonObject &data) override; + private: - QHash m_jobs; + bool m_supported = false; + }; diff --git a/host/main.cpp b/host/main.cpp index c44045df..9010d05a 100644 --- a/host/main.cpp +++ b/host/main.cpp @@ -1,100 +1,102 @@ /* Copyright (C) 2017 by Kai Uwe Broulik Copyright (C) 2017 by David Edmundson 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. */ #include #include #include #include #include "connection.h" #include "pluginmanager.h" #include "abstractbrowserplugin.h" #include "settings.h" #include "kdeconnectplugin.h" #include "downloadplugin.h" #include "tabsrunnerplugin.h" #include "mprisplugin.h" #include "purposeplugin.h" +#include "itineraryplugin.h" void msgHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg) { Q_UNUSED(type); Q_UNUSED(context); QJsonObject data; data[QStringLiteral("subsystem")] = QStringLiteral("debug"); switch(type) { case QtDebugMsg: case QtInfoMsg: data[QStringLiteral("action")] = QStringLiteral("debug"); break; default: data[QStringLiteral("action")] = QStringLiteral("warning"); } data[QStringLiteral("payload")] = QJsonObject({{QStringLiteral("message"), msg}}); Connection::self()->sendData(data); } int main(int argc, char *argv[]) { // otherwise when logging out, session manager will ask the host to quit // (it's a "regular X app" after all) and then the browser will complain qunsetenv("SESSION_MANAGER"); QApplication a(argc, argv); // otherwise will close when download job finishes a.setQuitOnLastWindowClosed(false); // applicationName etc will be set in Settings once the browser identifies to us qInstallMessageHandler(msgHandler); KCrash::initialize(); // NOTE if you add a new plugin here, make sure to adjust the // "DEFAULT_EXTENSION_SETTINGS" in constants.js or else it won't // even bother loading your shiny new plugin! PluginManager::self().addPlugin(&Settings::self()); PluginManager::self().addPlugin(new KDEConnectPlugin(&a)); PluginManager::self().addPlugin(new DownloadPlugin(&a)); PluginManager::self().addPlugin(new TabsRunnerPlugin(&a)); PluginManager::self().addPlugin(new MPrisPlugin(&a)); PluginManager::self().addPlugin(new PurposePlugin(&a)); + PluginManager::self().addPlugin(new ItineraryPlugin(&a)); // TODO make this prettier, also prevent unloading them at any cost PluginManager::self().loadPlugin(&Settings::self()); QString serviceName = QStringLiteral("org.kde.plasma.browser_integration"); if (!QDBusConnection::sessionBus().registerService(serviceName)) { // now try appending PID in case multiple hosts are running serviceName.append(QLatin1String("-")).append(QString::number(QCoreApplication::applicationPid())); if (!QDBusConnection::sessionBus().registerService(serviceName)) { qWarning() << "Failed to register DBus service name" << serviceName; } } return a.exec(); }