diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 6122067fbd..2c9aff70c1 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -7,6 +7,10 @@ "message": "Add Style", "description": "Title of the page for adding styles" }, + "alphaChannel": { + "message": "Opacity", + "description": "Label of color's opacity" + }, "appliesAdd": { "message": "Add", "description": "Label for the button to add an 'applies' entry" @@ -36,6 +40,14 @@ "message": "Applies to", "description": "Label for 'applies to' fields on the edit/add screen" }, + "appliesLineWidgetLabel": { + "message": "Display 'Applies to' info", + "description": "Label for the checkbox to display applies-to information in the single editor" + }, + "appliesLineWidgetWarning": { + "message": "Does not work with minified CSS", + "description": "A warning that applies-to information won't show properly with minified CSS" + }, "appliesRegexpOption": { "message": "URLs matching the regexp", "description": "Option to make the style apply to the entered string as a regular expression" @@ -44,6 +56,10 @@ "message": "Remove", "description": "Label for the button to remove an 'applies' entry" }, + "appliesRemoveError": { + "message": "Can not remove last 'applies to' entry", + "description": "Error displayed when the last 'applies' is going to be removed" + }, "appliesSpecify": { "message": "Specify", "description": "Label for the button to make a style apply only to specific sites" @@ -64,6 +80,10 @@ "message": "Apply all updates", "description": "Label for the button to apply all detected updates" }, + "author": { + "message": "Author", + "description": "Label for the style author" + }, "backupButtons": { "message": "Backup", "description": "Heading for backup" @@ -83,6 +103,10 @@ "updateCheckHistory": { "message": "History of update checks" }, + "configureStyle": { + "message": "Configure", + "description": "Label for the button to configure userstyle" + }, "checkForUpdate": { "message": "Check for update", "description": "Label for the button to check a single style for an update" @@ -167,6 +191,14 @@ "message": "No", "description": "'No' button in a confirm dialog" }, + "confirmDefault": { + "message": "Use default", + "description": "'Set to default' button in a confirm dialog" + }, + "confirmSave": { + "message": "Save", + "description": "'Save' button in a confirm dialog" + }, "confirmStop": { "message": "Stop", "description": "'Stop' button in a confirm dialog" @@ -175,6 +207,10 @@ "message": "Yes", "description": "'Yes' button in a confirm dialog" }, + "confirmClose": { + "message": "Close", + "description": "'Close' button in a confirm dialog" + }, "dbError": { "message": "An error has occurred using the Stylus database. Would you like to visit a web page with possible solutions?", "description": "Prompt when a DB error is encountered" @@ -257,6 +293,26 @@ "message": "Export", "description": "Label for the button to export a style ('edit' page) or all styles ('manage' page)" }, + "externalLink": { + "message": "External link", + "description": "Label for external links" + }, + "externalHomepage": { + "message": "Homepage", + "description": "Label for the external link to style's homepage" + }, + "externalSupport": { + "message": "Support", + "description": "Label for the external link to style's support site" + }, + "externalFeedback": { + "message": "Feedback", + "description": "Label for the external link to send feedback for the style" + }, + "externalUsercssDocument": { + "message": "Documentation for Usercss", + "description": "Label for the external link to usercss documentation" + }, "filteredStyles": { "message": "$numShown$ shown of $numTotal$ total", "description": "TL note - make this message short", @@ -345,10 +401,43 @@ "message": "Discard contents of current style and overwrite it with the imported style", "description": "Label for the button to import and overwrite current style" }, + "installButton": { + "message": "Install", + "description": "Label for install button" + }, + "installButtonInstalled": { + "message": "Installed", + "description": "Text displayed when the style is successfully installed" + }, + "installButtonUpdate": { + "message": "Update", + "description": "Label for update button" + }, + "installButtonReinstall": { + "message": "Reinstall", + "description": "Label for reinstall button" + }, "installUpdate": { "message": "Install update", "description": "Label for the button to install an update for a single style" }, + "installUpdateFrom": { + "message": "Currently the style is updated from $url$", + "description": "Label to describe where the style gets update", + "placeholders": { + "url": { + "content": "$1" + } + } + }, + "installUpdateFromLabel": { + "message": "Check for updates", + "description": "Label for the checkbox to save current URL for update check" + }, + "license": { + "message": "License", + "description": "Label for the license" + }, "linterConfigPopupTitle": { "message": "Set $linter$ rules configuration", "description": "Stylelint or CSSLint popup header", @@ -366,6 +455,15 @@ "message": "(Set rule as: 0 = disabled; 1 = warning; 2 = error)", "description": "CSSLint rule config values" }, + "linterCSSLintIncompatible": { + "message": "CSSLint doesn't support $preprocessorname$ preprocessor", + "description": "The label to display when the preprocessor isn't compatible with CSSLint", + "placeholders": { + "preprocessorname": { + "content": "$1" + } + } + }, "linterInvalidConfigError": { "message": "Not saved due to these invalid configuration settings:", "description": "Invalid linter config will show a message followed by a list of invalid entries" @@ -395,6 +493,14 @@ "message": "See a full list of rules", "description": "Stylelint or CSSLint rules label added immediately before a link" }, + "liveReloadLabel": { + "message": "Live reload", + "description": "The label of live-reload feature" + }, + "liveReloadError": { + "message": "An error occurred while watching the file", + "description": "The label of live-reload error" + }, "manageFilters": { "message": "Filters", "description": "Label for filters container" @@ -483,6 +589,10 @@ "message": "More Options", "description": "Subheading for options section on manage page." }, + "parseUsercssError": { + "message": "Stylus failed to parse usercss:", + "description": "The error message to show when stylus failed to parse usercss" + }, "popupManageTooltip": { "message": "Shift-click or right-click opens manager with styles applicable for current site", "description": "Tooltip for the 'Manage' button in the popup." @@ -629,6 +739,65 @@ } } }, + "styleInstallOverwrite" : { + "message": "'$stylename$' is already installed. Overwrite?\nVersion: $oldVersion$ -> $newVersion$", + "description": "Confirmation when re-installing a style", + "placeholders": { + "stylename": { + "content": "$1" + }, + "oldVersion": { + "content": "$2" + }, + "newVersion": { + "content": "$3" + } + } + }, + "styleInstallFailed": { + "message": "Failed to install userstyle!\n$error$", + "description": "Warning when installation failed", + "placeholders": { + "error": { + "content": "$1" + } + } + }, + "styleMetaErrorCheckbox": { + "message": "Invalid @var checkbox: value must be 0 or 1", + "description": "Error displayed when the value of @var checkbox is invalid" + }, + "styleMetaErrorColor": { + "message": "$color$ is not a valid color", + "description": "Error displayed when the value of @var color is invalid", + "placeholders": { + "color": { + "content": "$1" + } + } + }, + "styleMetaErrorPreprocessor": { + "message": "Unsupported @preprocessor: $preprocessor$", + "description": "Error displayed when the value of @preprocessor is not supported", + "placeholders": { + "preprocessor": { + "content": "$1" + } + } + }, + "styleMetaErrorSelectValueMismatch": { + "message": "Invalid @select: value doesn't exist in the list", + "description": "Error displayed when the value of @select is invalid" + }, + "styleMissingMeta": { + "message": "Missing metadata @$key$", + "description": "Error displayed when a mandatory metadata is missing", + "placeholders": { + "key": { + "content": "$1" + } + } + }, "styleMissingName": { "message": "Enter a name.", "description": "Error displayed when user saves without providing a name" @@ -645,6 +814,10 @@ "message": "Mozilla Format", "description": "Heading for the section with buttons to import/export Mozilla format of the style" }, + "styleFromMozillaFormatError": { + "message": "Failed to import from Mozilla format", + "description": "Label for the import error" + }, "styleFromMozillaFormatPrompt": { "message": "Paste the Mozilla-format code", "description": "Prompt in the dialog displayed after clicking 'Import from Mozilla format' button" @@ -666,6 +839,10 @@ } } }, + "styleUpdateDiscardChanges": { + "message": "The style is changed outside of the editor. Would you like to reload the style?", + "description": "Confirmation to update the style in the editor" + }, "stylusUnavailableForURL": { "message": "Stylus doesn't work on pages like this.", "description": "Note in the toolbar pop-up when on a URL Stylus can't affect" @@ -743,6 +920,10 @@ "message": "Updates installed:", "description": "Text that displays when an update is installed on options page. Followed by the number of currently installed updates." }, + "versionInvalidOlder": { + "message": "The version is older than the installed style.", + "description": "Displayed when the version of style is older than the installed one" + }, "writeStyleFor": { "message": "Write style for: ", "description": "Label for toolbar pop-up that precedes the links to write a new style" @@ -805,6 +986,9 @@ "optionsAdvancedContextDelete": { "message": "Add 'Delete' in editor context menu" }, + "optionsAdvancedNewStyleAsUsercss": { + "message": "Write new style as usercss" + }, "optionsActions": { "message": "Actions" }, diff --git a/background/background.js b/background/background.js index 6ab3711ae2..0d5be06573 100644 --- a/background/background.js +++ b/background/background.js @@ -1,5 +1,6 @@ /* global dbExec, getStyles, saveStyle */ /* global handleCssTransitionBug */ +/* global usercssHelper openEditor */ 'use strict'; // eslint-disable-next-line no-var @@ -302,6 +303,14 @@ function onRuntimeMessage(request, sender, sendResponse) { saveStyle(request).then(sendResponse); return KEEP_CHANNEL_OPEN; + case 'saveUsercss': + usercssHelper.save(request, true).then(sendResponse); + return KEEP_CHANNEL_OPEN; + + case 'buildUsercss': + usercssHelper.build(request, true).then(sendResponse); + return KEEP_CHANNEL_OPEN; + case 'healthCheck': dbExec() .then(() => sendResponse(true)) @@ -313,5 +322,36 @@ function onRuntimeMessage(request, sender, sendResponse) { .then(sendResponse) .catch(() => sendResponse(null)); return KEEP_CHANNEL_OPEN; + + case 'openUsercssInstallPage': + usercssHelper.openInstallPage(sender.tab.id, request).then(sendResponse); + return KEEP_CHANNEL_OPEN; + + case 'closeTab': + closeTab(sender.tab.id, request).then(sendResponse); + return KEEP_CHANNEL_OPEN; + + case 'openEditor': + openEditor(request.id); + return; } } + +function closeTab(tabId, request) { + return new Promise(resolve => { + if (request.tabId) { + tabId = request.tabId; + } + chrome.tabs.remove(tabId, () => { + const {lastError} = chrome.runtime; + if (lastError) { + resolve({ + success: false, + error: lastError.message || String(lastError) + }); + return; + } + resolve({success: true}); + }); + }); +} diff --git a/background/storage.js b/background/storage.js index fe8a222ba7..fe6a98fcaa 100644 --- a/background/storage.js +++ b/background/storage.js @@ -381,20 +381,29 @@ function saveStyle(style) { } let existed; let codeIsUpdated; - if (reason === 'update' || reason === 'update-digest') { - return calcStyleDigest(style).then(digest => { - style.originalDigest = digest; - return decide(); - }); + + return maybeCalcDigest() + .then(maybeImportFix) + .then(decide); + + function maybeCalcDigest() { + if (reason === 'update' || reason === 'update-digest') { + return calcStyleDigest(style).then(digest => { + style.originalDigest = digest; + }); + } + return Promise.resolve(); } - if (reason === 'import') { - style.originalDigest = style.originalDigest || style.styleDigest; // TODO: remove in the future - delete style.styleDigest; // TODO: remove in the future - if (typeof style.originalDigest !== 'string' || style.originalDigest.length !== 40) { - delete style.originalDigest; + + function maybeImportFix() { + if (reason === 'import') { + style.originalDigest = style.originalDigest || style.styleDigest; // TODO: remove in the future + delete style.styleDigest; // TODO: remove in the future + if (typeof style.originalDigest !== 'string' || style.originalDigest.length !== 40) { + delete style.originalDigest; + } } } - return decide(); function decide() { if (id !== null) { @@ -712,7 +721,8 @@ function normalizeStyleSections({sections}) { function calcStyleDigest(style) { - const jsonString = JSON.stringify(normalizeStyleSections(style)); + const jsonString = style.usercssData ? + style.sourceCode : JSON.stringify(normalizeStyleSections(style)); const text = new TextEncoder('utf-8').encode(jsonString); return crypto.subtle.digest('SHA-1', text).then(hex); diff --git a/background/update.js b/background/update.js index c400a29b64..36adcde749 100644 --- a/background/update.js +++ b/background/update.js @@ -1,5 +1,6 @@ /* global getStyles, saveStyle, styleSectionsEqual, chromeLocal */ /* global calcStyleDigest */ +/* global usercss semverCompare usercssHelper */ 'use strict'; // eslint-disable-next-line no-var @@ -15,8 +16,10 @@ var updater = { MAYBE_EDITED: 'may be locally edited', SAME_MD5: 'up-to-date: MD5 is unchanged', SAME_CODE: 'up-to-date: code sections are unchanged', + SAME_VERSION: 'up-to-date: version is unchanged', ERROR_MD5: 'error: MD5 is invalid', ERROR_JSON: 'error: JSON is invalid', + ERROR_VERSION: 'error: version is older than installed style', lastUpdateTime: parseInt(localStorage.lastUpdateTime) || Date.now(), @@ -53,9 +56,11 @@ var updater = { 'ignoreDigest' option is set on the second manual individual update check on the manage page. */ + const maybeUpdate = style.usercssData ? maybeUpdateUsercss : maybeUpdateUSO; return (ignoreDigest ? Promise.resolve() : calcStyleDigest(style)) - .then(maybeFetchMd5) - .then(maybeFetchCode) + .then(checkIfEdited) + .then(maybeUpdate) + .then(maybeValidate) .then(maybeSave) .then(saved => { observer(updater.UPDATED, saved); @@ -67,42 +72,79 @@ var updater = { updater.log(updater.SKIPPED + ` (${err}) #${style.id} ${style.name}`); }); - function maybeFetchMd5(digest) { - if (!ignoreDigest && style.originalDigest && style.originalDigest !== digest) { + function checkIfEdited(digest) { + if (ignoreDigest) { + return; + } + if (style.originalDigest && style.originalDigest !== digest) { return Promise.reject(updater.EDITED); } - return download(style.md5Url); } - function maybeFetchCode(md5) { - if (!md5 || md5.length !== 32) { - return Promise.reject(updater.ERROR_MD5); - } - if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) { - return Promise.reject(updater.SAME_MD5); - } - return download(style.updateUrl); + function maybeUpdateUSO() { + return download(style.md5Url).then(md5 => { + if (!md5 || md5.length !== 32) { + return Promise.reject(updater.ERROR_MD5); + } + if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) { + return Promise.reject(updater.SAME_MD5); + } + return download(style.updateUrl) + .then(text => tryJSONparse(text)); + }); } - function maybeSave(text) { - const json = tryJSONparse(text); + function maybeUpdateUsercss() { + return download(style.updateUrl).then(text => { + const json = usercss.buildMeta(text); + const {usercssData: {version}} = style; + const {usercssData: {version: newVersion}} = json; + switch (Math.sign(semverCompare(version, newVersion))) { + case 0: + // re-install is invalid in a soft upgrade + if (!ignoreDigest) { + return Promise.reject(updater.SAME_VERSION); + } + break; + case 1: + // downgrade is always invalid + return Promise.reject(updater.ERROR_VERSION); + } + return usercss.buildCode(json); + }); + } + + function maybeValidate(json) { + if (json.usercssData) { + // usercss is already validated while building + return json; + } if (!styleJSONseemsValid(json)) { return Promise.reject(updater.ERROR_JSON); } + return json; + } + + function maybeSave(json) { json.id = style.id; if (styleSectionsEqual(json, style)) { // JSONs may have different order of items even if sections are effectively equal // so we'll update the digest anyway + // always update digest even if (save === false) saveStyle(Object.assign(json, {reason: 'update-digest'})); return Promise.reject(updater.SAME_CODE); } else if (!style.originalDigest && !ignoreDigest) { return Promise.reject(updater.MAYBE_EDITED); } - return !save ? json : - saveStyle(Object.assign(json, { - name: null, // keep local name customizations - reason: 'update', - })); + if (!save) { + return json; + } + json.reason = 'update'; + if (json.usercssData) { + return usercssHelper.save(json); + } + json.name = null; // keep local name customizations + return saveStyle(json); } function styleJSONseemsValid(json) { diff --git a/background/usercss-helper.js b/background/usercss-helper.js new file mode 100644 index 0000000000..c3a8432c8e --- /dev/null +++ b/background/usercss-helper.js @@ -0,0 +1,89 @@ +/* global usercss saveStyle getStyles */ +'use strict'; + +// eslint-disable-next-line no-var +var usercssHelper = (() => { + function buildMeta(style) { + if (style.usercssData) { + return Promise.resolve(style); + } + try { + const {sourceCode} = style; + // allow sourceCode to be normalized + delete style.sourceCode; + return Promise.resolve(Object.assign(usercss.buildMeta(sourceCode), style)); + } catch (e) { + return Promise.reject(e); + } + } + + function buildCode(style) { + return usercss.buildCode(style); + } + + function wrapReject(pending) { + return pending.then(result => ({success: true, result})) + .catch(err => ({success: false, result: err.message || String(err)})); + } + + // Parse the source and find the duplication + function build({sourceCode, checkDup = false}, noReject) { + const pending = buildMeta({sourceCode}) + .then(style => Promise.all([ + buildCode(style), + checkDup && findDup(style) + ])) + .then(([style, dup]) => ({style, dup})); + + return noReject ? wrapReject(pending) : pending; + } + + function save(style, noReject) { + const pending = buildMeta(style) + .then(assignVars) + .then(buildCode) + .then(saveStyle); + + return noReject ? wrapReject(pending) : pending; + + function assignVars(style) { + if (style.reason === 'config' && style.id) { + return style; + } + return findDup(style).then(dup => { + if (dup) { + style.id = dup.id; + if (style.reason !== 'config') { + // preserve style.vars during update + usercss.assignVars(style, dup); + } + } + return style; + }); + } + } + + function findDup(style) { + if (style.id) { + return getStyles({id: style.id}).then(s => s[0]); + } + return getStyles().then(styles => + styles.find(target => { + if (!target.usercssData) { + return false; + } + return target.usercssData.name === style.usercssData.name && + target.usercssData.namespace === style.usercssData.namespace; + }) + ); + } + + function openInstallPage(tabId, request) { + const url = '/install-usercss.html' + + '?updateUrl=' + encodeURIComponent(request.updateUrl) + + '&tabId=' + tabId; + return wrapReject(openURL({url})); + } + + return {build, save, findDup, openInstallPage}; +})(); diff --git a/content/install-user-css.js b/content/install-user-css.js new file mode 100644 index 0000000000..150199c240 --- /dev/null +++ b/content/install-user-css.js @@ -0,0 +1,122 @@ +/* global runtimeSend */ +'use strict'; + +function createSourceLoader() { + let source; + + function fetchText(url) { + return new Promise((resolve, reject) => { + // you can't use fetch in Chrome under 'file:' protocol + const xhr = new XMLHttpRequest(); + xhr.open('GET', url); + xhr.addEventListener('load', () => resolve(xhr.responseText)); + xhr.addEventListener('error', () => reject(xhr)); + xhr.send(); + }); + } + + function load() { + return fetchText(location.href).then(newSource => { + source = newSource; + return source; + }); + } + + function watch(cb) { + let timer; + const DELAY = 1000; + + function start() { + if (timer) { + return; + } + timer = setTimeout(check, DELAY); + } + + function stop() { + clearTimeout(timer); + timer = null; + } + + function check() { + fetchText(location.href) + .then(newSource => { + if (source !== newSource) { + source = newSource; + return cb(source); + } + }) + .catch(error => { + console.log(t('liveReloadError', error)); + }) + .then(() => { + timer = setTimeout(check, DELAY); + }); + } + + return {start, stop}; + } + + return {load, watch, source: () => source}; +} + +function initUsercssInstall() { + const sourceLoader = createSourceLoader(); + const pendingSource = sourceLoader.load(); + let watcher; + + chrome.runtime.onConnect.addListener(port => { + // FIXME: is this the correct way to reject a connection? + // https://developer.chrome.com/extensions/messaging#connect + console.assert(port.name === 'usercss-install'); + + port.onMessage.addListener(msg => { + switch (msg.method) { + case 'getSourceCode': + pendingSource + .then(sourceCode => port.postMessage({method: msg.method + 'Response', sourceCode})) + .catch(err => port.postMessage({method: msg.method + 'Response', error: err.message || String(err)})); + break; + + case 'liveReloadStart': + if (!watcher) { + watcher = sourceLoader.watch(sourceCode => { + port.postMessage({method: 'sourceCodeChanged', sourceCode}); + }); + } + watcher.start(); + break; + + case 'liveReloadStop': + watcher.stop(); + break; + + case 'closeTab': + if (history.length > 1) { + history.back(); + } else { + runtimeSend({method: 'closeTab'}); + } + break; + } + }); + }); + return runtimeSend({ + method: 'openUsercssInstallPage', + updateUrl: location.href + }).catch(alert); +} + +function isUsercss() { + if (!/text\/(css|plain)/.test(document.contentType)) { + return false; + } + if (!/==userstyle==/i.test(document.body.textContent)) { + return false; + } + return true; +} + +if (isUsercss()) { + initUsercssInstall(); +} diff --git a/content/util.js b/content/util.js new file mode 100644 index 0000000000..34160e87bd --- /dev/null +++ b/content/util.js @@ -0,0 +1,10 @@ +'use strict'; + +function runtimeSend(request) { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage( + request, + ({success, result}) => (success ? resolve : reject)(result) + ); + }); +} diff --git a/edit.html b/edit.html index bc1527f647..e1dd9fce88 100644 --- a/edit.html +++ b/edit.html @@ -17,9 +17,15 @@ + + + + + + @@ -50,9 +56,14 @@ + + + + + + +