import-export.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. /* global messageBox, handleUpdate, applyOnMessage */
  2. 'use strict';
  3. const STYLISH_DUMP_FILE_EXT = '.txt';
  4. const STYLUS_BACKUP_FILE_EXT = '.json';
  5. function importFromFile({fileTypeFilter, file} = {}) {
  6. return new Promise(resolve => {
  7. const fileInput = document.createElement('input');
  8. if (file) {
  9. readFile();
  10. return;
  11. }
  12. fileInput.style.display = 'none';
  13. fileInput.type = 'file';
  14. fileInput.accept = fileTypeFilter || STYLISH_DUMP_FILE_EXT;
  15. fileInput.acceptCharset = 'utf-8';
  16. document.body.appendChild(fileInput);
  17. fileInput.initialValue = fileInput.value;
  18. fileInput.onchange = readFile;
  19. fileInput.click();
  20. function readFile() {
  21. if (file || fileInput.value !== fileInput.initialValue) {
  22. file = file || fileInput.files[0];
  23. if (file.size > 100e6) {
  24. console.warn("100MB backup? I don't believe you.");
  25. importFromString('').then(resolve);
  26. return;
  27. }
  28. document.body.style.cursor = 'wait';
  29. const fReader = new FileReader();
  30. fReader.onloadend = event => {
  31. fileInput.remove();
  32. const text = event.target.result;
  33. const maybeUsercss = !/^[\s\r\n]*\[/.test(text) &&
  34. (text.includes('==UserStyle==') || /==UserStyle==/i.test(text));
  35. (!maybeUsercss ?
  36. importFromString(text) :
  37. getOwnTab().then(tab => {
  38. tab.url = URL.createObjectURL(new Blob([text], {type: 'text/css'}));
  39. return BG.usercssHelper.openInstallPage(tab, {direct: true})
  40. .then(() => URL.revokeObjectURL(tab.url));
  41. })
  42. ).then(numStyles => {
  43. document.body.style.cursor = '';
  44. resolve(numStyles);
  45. });
  46. };
  47. fReader.readAsText(file, 'utf-8');
  48. }
  49. }
  50. });
  51. }
  52. function importFromString(jsonString) {
  53. if (!BG) {
  54. onBackgroundReady().then(() => importFromString(jsonString));
  55. return;
  56. }
  57. // create objects in background context
  58. const json = BG.tryJSONparse(jsonString) || [];
  59. if (typeof json.slice !== 'function') {
  60. json.length = 0;
  61. }
  62. const oldStyles = json.length && BG.deepCopy(BG.cachedStyles.list || []);
  63. const oldStylesByName = json.length && new Map(
  64. oldStyles.map(style => [style.name.trim(), style]));
  65. const stats = {
  66. added: {names: [], ids: [], legend: 'importReportLegendAdded'},
  67. unchanged: {names: [], ids: [], legend: 'importReportLegendIdentical'},
  68. metaAndCode: {names: [], ids: [], legend: 'importReportLegendUpdatedBoth'},
  69. metaOnly: {names: [], ids: [], legend: 'importReportLegendUpdatedMeta'},
  70. codeOnly: {names: [], ids: [], legend: 'importReportLegendUpdatedCode'},
  71. invalid: {names: [], legend: 'importReportLegendInvalid'},
  72. };
  73. let index = 0;
  74. let lastRenderTime = performance.now();
  75. const renderQueue = [];
  76. const RENDER_NAP_TIME_MAX = 1000; // ms
  77. const RENDER_QUEUE_MAX = 50; // number of styles
  78. const SAVE_OPTIONS = {reason: 'import', notify: false};
  79. return new Promise(proceed);
  80. function proceed(resolve) {
  81. while (index < json.length) {
  82. const item = json[index++];
  83. const info = analyze(item);
  84. if (info) {
  85. // using saveStyle directly since json was parsed in background page context
  86. return BG.saveStyle(Object.assign(item, SAVE_OPTIONS))
  87. .then(style => account({style, info, resolve}));
  88. }
  89. }
  90. renderQueue.forEach(style => handleUpdate(style, {reason: 'import'}));
  91. renderQueue.length = 0;
  92. done(resolve);
  93. }
  94. function analyze(item) {
  95. if (!item || !item.name || !item.name.trim() || typeof item !== 'object'
  96. || (item.sections && typeof item.sections.slice !== 'function')) {
  97. stats.invalid.names.push(`#${index}: ${limitString(item && item.name || '')}`);
  98. return;
  99. }
  100. item.name = item.name.trim();
  101. const byId = BG.cachedStyles.byId.get(item.id);
  102. const byName = oldStylesByName.get(item.name);
  103. oldStylesByName.delete(item.name);
  104. let oldStyle;
  105. if (byId) {
  106. if (sameStyle(byId, item)) {
  107. oldStyle = byId;
  108. } else {
  109. item.id = null;
  110. }
  111. }
  112. if (!oldStyle && byName) {
  113. item.id = byName.id;
  114. oldStyle = byName;
  115. }
  116. const oldStyleKeys = oldStyle && Object.keys(oldStyle);
  117. const metaEqual = oldStyleKeys &&
  118. oldStyleKeys.length === Object.keys(item).length &&
  119. oldStyleKeys.every(k => k === 'sections' || oldStyle[k] === item[k]);
  120. const codeEqual = oldStyle && BG.styleSectionsEqual(oldStyle, item);
  121. if (metaEqual && codeEqual) {
  122. stats.unchanged.names.push(oldStyle.name);
  123. stats.unchanged.ids.push(oldStyle.id);
  124. return;
  125. }
  126. return {oldStyle, metaEqual, codeEqual};
  127. }
  128. function sameStyle(oldStyle, newStyle) {
  129. return oldStyle.name.trim() === newStyle.name.trim() ||
  130. ['updateUrl', 'originalMd5', 'originalDigest']
  131. .some(field => oldStyle[field] && oldStyle[field] === newStyle[field]);
  132. }
  133. function account({style, info, resolve}) {
  134. renderQueue.push(style);
  135. if (performance.now() - lastRenderTime > RENDER_NAP_TIME_MAX
  136. || renderQueue.length > RENDER_QUEUE_MAX) {
  137. renderQueue.forEach(style => handleUpdate(style, {reason: 'import'}));
  138. setTimeout(scrollElementIntoView, 0, $('#style-' + renderQueue.pop().id));
  139. renderQueue.length = 0;
  140. lastRenderTime = performance.now();
  141. }
  142. setTimeout(proceed, 0, resolve);
  143. const {oldStyle, metaEqual, codeEqual} = info;
  144. if (!oldStyle) {
  145. stats.added.names.push(style.name);
  146. stats.added.ids.push(style.id);
  147. return;
  148. }
  149. if (!metaEqual && !codeEqual) {
  150. stats.metaAndCode.names.push(reportNameChange(oldStyle, style));
  151. stats.metaAndCode.ids.push(style.id);
  152. return;
  153. }
  154. if (!codeEqual) {
  155. stats.codeOnly.names.push(style.name);
  156. stats.codeOnly.ids.push(style.id);
  157. return;
  158. }
  159. stats.metaOnly.names.push(reportNameChange(oldStyle, style));
  160. stats.metaOnly.ids.push(style.id);
  161. }
  162. function done(resolve) {
  163. const numChanged = stats.metaAndCode.names.length +
  164. stats.metaOnly.names.length +
  165. stats.codeOnly.names.length +
  166. stats.added.names.length;
  167. Promise.resolve(numChanged && refreshAllTabs()).then(() => {
  168. const report = Object.keys(stats)
  169. .filter(kind => stats[kind].names.length)
  170. .map(kind => {
  171. const {ids, names, legend} = stats[kind];
  172. const listItemsWithId = (name, i) =>
  173. $create('div', {dataset: {id: ids[i]}}, name);
  174. const listItems = name =>
  175. $create('div', name);
  176. const block =
  177. $create('details', {dataset: {id: kind}}, [
  178. $create('summary',
  179. $create('b', names.length + ' ' + t(legend))),
  180. $create('small',
  181. names.map(ids ? listItemsWithId : listItems)),
  182. ]);
  183. return block;
  184. });
  185. scrollTo(0, 0);
  186. messageBox({
  187. title: t('importReportTitle'),
  188. contents: report.length ? report : t('importReportUnchanged'),
  189. buttons: [t('confirmOK'), numChanged && t('undo')],
  190. onshow: bindClick,
  191. }).then(({button}) => {
  192. if (button === 1) {
  193. undo();
  194. }
  195. });
  196. resolve(numChanged);
  197. });
  198. }
  199. function undo() {
  200. const oldStylesById = new Map(oldStyles.map(style => [style.id, style]));
  201. const newIds = [
  202. ...stats.metaAndCode.ids,
  203. ...stats.metaOnly.ids,
  204. ...stats.codeOnly.ids,
  205. ...stats.added.ids,
  206. ];
  207. let resolve;
  208. index = 0;
  209. return new Promise(resolve_ => {
  210. resolve = resolve_;
  211. undoNextId();
  212. }).then(refreshAllTabs)
  213. .then(() => messageBox({
  214. title: t('importReportUndoneTitle'),
  215. contents: newIds.length + ' ' + t('importReportUndone'),
  216. buttons: [t('confirmOK')],
  217. }));
  218. function undoNextId() {
  219. if (index === newIds.length) {
  220. resolve();
  221. return;
  222. }
  223. const id = newIds[index++];
  224. deleteStyleSafe({id, notify: false}).then(id => {
  225. const oldStyle = oldStylesById.get(id);
  226. if (oldStyle) {
  227. saveStyleSafe(Object.assign(oldStyle, SAVE_OPTIONS))
  228. .then(undoNextId);
  229. } else {
  230. undoNextId();
  231. }
  232. });
  233. }
  234. }
  235. function bindClick() {
  236. const highlightElement = event => {
  237. const styleElement = $('#style-' + event.target.dataset.id);
  238. if (styleElement) {
  239. scrollElementIntoView(styleElement);
  240. animateElement(styleElement);
  241. }
  242. };
  243. for (const block of $$('details')) {
  244. if (block.dataset.id !== 'invalid') {
  245. block.style.cursor = 'pointer';
  246. block.onclick = highlightElement;
  247. }
  248. }
  249. }
  250. function limitString(s, limit = 100) {
  251. return s.length <= limit ? s : s.substr(0, limit) + '...';
  252. }
  253. function reportNameChange(oldStyle, newStyle) {
  254. return newStyle.name !== oldStyle.name
  255. ? oldStyle.name + ' —> ' + newStyle.name
  256. : oldStyle.name;
  257. }
  258. function refreshAllTabs() {
  259. return Promise.all([
  260. getActiveTab(),
  261. getOwnTab(),
  262. ]).then(([activeTab, ownTab]) => new Promise(resolve => {
  263. // list all tabs including chrome-extension:// which can be ours
  264. queryTabs().then(tabs => {
  265. const lastTab = tabs[tabs.length - 1];
  266. for (const tab of tabs) {
  267. // skip lazy-loaded aka unloaded tabs that seem to start loading on message in FF
  268. if (FIREFOX && !tab.width) {
  269. if (tab === lastTab) {
  270. resolve();
  271. }
  272. continue;
  273. }
  274. getStylesSafe({matchUrl: tab.url, enabled: true, asHash: true}).then(styles => {
  275. const message = {method: 'styleReplaceAll', styles};
  276. if (tab.id === ownTab.id) {
  277. applyOnMessage(message);
  278. } else {
  279. message.tabId = tab.id;
  280. invokeOrPostpone(tab.id === activeTab.id, sendMessage, message, ignoreChromeError);
  281. }
  282. setTimeout(BG.updateIcon, 0, tab, styles);
  283. if (tab === lastTab) {
  284. resolve();
  285. }
  286. });
  287. }
  288. });
  289. }));
  290. }
  291. }
  292. $('#file-all-styles').onclick = () => {
  293. getStylesSafe().then(styles => {
  294. const text = JSON.stringify(styles, null, '\t');
  295. const blob = new Blob([text], {type: 'application/json'});
  296. const objectURL = URL.createObjectURL(blob);
  297. let link = $create('a', {
  298. href: objectURL,
  299. type: 'application/json',
  300. download: generateFileName(),
  301. });
  302. // TODO: remove the fallback when FF multi-process bug is fixed
  303. if (!FIREFOX) {
  304. link.dispatchEvent(new MouseEvent('click'));
  305. setTimeout(() => URL.revokeObjectURL(objectURL));
  306. } else {
  307. const iframe = document.body.appendChild($create('iframe', {
  308. style: 'width: 0; height: 0; position: fixed; opacity: 0;'.replace(/;/g, '!important;'),
  309. }));
  310. doTimeout()
  311. .then(() => {
  312. link = iframe.contentDocument.importNode(link, true);
  313. iframe.contentDocument.body.appendChild(link);
  314. })
  315. .then(() => doTimeout())
  316. .then(() => link.dispatchEvent(new MouseEvent('click')))
  317. .then(() => doTimeout(1000))
  318. .then(() => {
  319. URL.revokeObjectURL(objectURL);
  320. iframe.remove();
  321. });
  322. }
  323. });
  324. function doTimeout(ms) {
  325. return new Promise(resolve => setTimeout(resolve, ms));
  326. }
  327. function generateFileName() {
  328. const today = new Date();
  329. const dd = ('0' + today.getDate()).substr(-2);
  330. const mm = ('0' + (today.getMonth() + 1)).substr(-2);
  331. const yyyy = today.getFullYear();
  332. return `stylus-${yyyy}-${mm}-${dd}${STYLUS_BACKUP_FILE_EXT}`;
  333. }
  334. };
  335. $('#unfile-all-styles').onclick = () => {
  336. importFromFile({fileTypeFilter: STYLUS_BACKUP_FILE_EXT});
  337. };
  338. Object.assign(document.body, {
  339. ondragover(event) {
  340. const hasFiles = event.dataTransfer.types.includes('Files');
  341. event.dataTransfer.dropEffect = hasFiles || event.target.type === 'search' ? 'copy' : 'none';
  342. this.classList.toggle('dropzone', hasFiles);
  343. if (hasFiles) {
  344. event.preventDefault();
  345. clearTimeout(this.fadeoutTimer);
  346. this.classList.remove('fadeout');
  347. }
  348. },
  349. ondragend() {
  350. animateElement(this, {className: 'fadeout', removeExtraClasses: ['dropzone']}).then(() => {
  351. this.style.animationDuration = '';
  352. });
  353. },
  354. ondragleave(event) {
  355. try {
  356. // in Firefox event.target could be XUL browser and hence there is no permission to access it
  357. if (event.target === this) {
  358. this.ondragend();
  359. }
  360. } catch (e) {
  361. this.ondragend();
  362. }
  363. },
  364. ondrop(event) {
  365. this.ondragend();
  366. if (event.dataTransfer.files.length) {
  367. event.preventDefault();
  368. if ($('#only-updates input').checked) {
  369. $('#only-updates input').click();
  370. }
  371. importFromFile({file: event.dataTransfer.files[0]});
  372. }
  373. },
  374. });