You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

511 lines
12 KiB

  1. const tabs = require('modulekit-tabs')
  2. const yaml = require('js-yaml')
  3. const md5 = require('md5')
  4. const OverpassLayer = require('overpass-layer')
  5. const OverpassFrontendFilter = require('overpass-frontend/src/Filter')
  6. const jsonMultilineStrings = require('json-multiline-strings')
  7. const Window = require('./Window')
  8. const OpenStreetBrowserLoader = require('./OpenStreetBrowserLoader')
  9. const editors = []
  10. class CustomCategoryRepository {
  11. constructor () {
  12. this.clearCache()
  13. }
  14. load (callback) {
  15. callback(null)
  16. }
  17. clearCache () {
  18. this.cache = {}
  19. }
  20. listCategories (options, callback) {
  21. fetch('customCategory.php?action=list')
  22. .then(res => res.json())
  23. .then(result => callback(null, result))
  24. }
  25. getCategory (id, options, callback) {
  26. if (id in this.cache) {
  27. const data = this.parseCategory(id, this.cache[id])
  28. return callback(null, data, this.cache[id])
  29. }
  30. fetch('customCategory.php?id=' + id)
  31. .then(res => res.text())
  32. .then(content => {
  33. this.cache[id] = content
  34. const data = this.parseCategory(id, content)
  35. callback(null, data, content)
  36. })
  37. }
  38. parseCategory (id, content) {
  39. let data
  40. try {
  41. data = yaml.load(content)
  42. } catch (e) {
  43. return global.alert(e)
  44. }
  45. if (data && typeof data !== 'object') {
  46. return new Error('Data can not be parsed into an object')
  47. }
  48. if (!data.name) {
  49. data.name = 'Custom ' + id.substr(0, 6)
  50. }
  51. return data
  52. }
  53. saveCategory (body, options, callback) {
  54. const id = md5(body)
  55. this.cache[id] = body
  56. fetch('customCategory.php?action=save', {
  57. method: 'POST',
  58. body
  59. })
  60. }
  61. getTemplate (id, options, callback) {
  62. callback(null, '')
  63. }
  64. }
  65. class CustomCategoryEditor {
  66. constructor (repository) {
  67. this.repository = repository
  68. editors.push(this)
  69. }
  70. load (id, callback) {
  71. this.repository.getCategory(id, {},
  72. (err, category, content) => {
  73. this.content = content
  74. callback(err, content)
  75. })
  76. }
  77. edit () {
  78. if (this.window) {
  79. this.window.focused = true
  80. return
  81. }
  82. this.window = new Window({
  83. title: 'Custom Category'
  84. })
  85. this.window.on('close', () => {
  86. this.window = null
  87. })
  88. this.textarea = document.createElement('textarea')
  89. this.textarea.spellcheck = false
  90. this.window.content.appendChild(this.textarea)
  91. if (this.content !== undefined) {
  92. this.textarea.value = this.content
  93. }
  94. const controls = document.createElement('div')
  95. controls.className = 'controls'
  96. this.window.content.appendChild(controls)
  97. const input = document.createElement('input')
  98. input.type = 'submit'
  99. input.value = lang('apply-keep')
  100. controls.appendChild(input)
  101. const inputClose = document.createElement('input')
  102. inputClose.type = 'button'
  103. inputClose.value = lang('apply-close')
  104. inputClose.onclick = () => {
  105. if (this.submit()) {
  106. this.window.close()
  107. }
  108. }
  109. controls.appendChild(inputClose)
  110. const icons = document.createElement('div')
  111. icons.className = 'actions'
  112. controls.appendChild(icons)
  113. this.inputDownload = document.createElement('a')
  114. this.textarea.onchange = () => this.updateDownload()
  115. this.updateDownload()
  116. this.inputDownload.title = lang('download')
  117. this.inputDownload.innerHTML = '<i class="fas fa-download"></i>'
  118. icons.appendChild(this.inputDownload)
  119. const tutorial = document.createElement('span')
  120. tutorial.className = 'tip-tutorial'
  121. let text = lang('tip-tutorial')
  122. text = text.replace('[', '<a target="_blank" href="https://github.com/plepe/OpenStreetBrowser/blob/master/doc/Tutorial.md">')
  123. text = text.replace(']', '</a>')
  124. tutorial.innerHTML = text
  125. controls.appendChild(tutorial)
  126. input.onclick = (e) => {
  127. this.submit()
  128. e.preventDefault()
  129. }
  130. this.window.show()
  131. }
  132. submit () {
  133. const err = customCategoryTest(this.textarea.value)
  134. if (err) {
  135. global.alert(err)
  136. return false
  137. }
  138. this.applyContent(this.textarea.value)
  139. return true
  140. }
  141. applyContent (content) {
  142. this.content = content
  143. this.repository.saveCategory(this.content, {}, () => {})
  144. if (this.textarea) {
  145. this.textarea.value = content
  146. this.updateDownload()
  147. }
  148. const id = md5(content)
  149. this.id = id
  150. if (this.category) {
  151. this.category.remove()
  152. this.category = null
  153. }
  154. OpenStreetBrowserLoader.getCategory('custom/' + id, {}, (err, category) => {
  155. if (err) {
  156. return global.alert(err)
  157. }
  158. this.category = category
  159. this.category.setParentDom(document.getElementById('contentListAddCategories'))
  160. this.category.open()
  161. })
  162. }
  163. updateDownload () {
  164. const file = new Blob([this.textarea.value], { type: 'application/yaml' })
  165. this.inputDownload.href = URL.createObjectURL(file)
  166. this.inputDownload.download = md5(this.textarea.value) + '.yaml'
  167. }
  168. }
  169. function editCustomCategory (id, category) {
  170. const done = editors.filter(editor => {
  171. if (editor.id === id) {
  172. editor.edit()
  173. return true
  174. }
  175. })
  176. if (!done.length) {
  177. const editor = new CustomCategoryEditor(repository)
  178. editor.load(id, (err) => {
  179. if (err) { return global.alert(err) }
  180. editor.category = category
  181. editor.edit()
  182. })
  183. }
  184. }
  185. hooks.register('browser-more-categories', (browser, parameters) => {
  186. const content = browser.dom
  187. if (!Object.keys(parameters).length) {
  188. const block = document.createElement('div')
  189. block.setAttribute('weight', 0)
  190. content.appendChild(block)
  191. const header = document.createElement('h4')
  192. header.innerHTML = lang('customCategory:header')
  193. block.appendChild(header)
  194. const ul = document.createElement('ul')
  195. let li = document.createElement('li')
  196. let a = document.createElement('a')
  197. a.innerHTML = lang('customCategory:create')
  198. a.href = '#'
  199. a.onclick = (e) => {
  200. const editor = new CustomCategoryEditor(repository)
  201. editor.edit()
  202. browser.close()
  203. e.preventDefault()
  204. }
  205. li.appendChild(a)
  206. ul.appendChild(li)
  207. li = document.createElement('li')
  208. a = document.createElement('a')
  209. a.innerHTML = lang('customCategory:list')
  210. a.href = '#more-categories?custom=list'
  211. li.appendChild(a)
  212. ul.appendChild(li)
  213. block.appendChild(ul)
  214. browser.catchLinks()
  215. } else if (parameters.custom === 'list') {
  216. customCategoriesList(browser, parameters)
  217. }
  218. })
  219. function customCategoriesList (browser, options) {
  220. browser.dom.innerHTML = '<i class="fa fa-spinner fa-pulse fa-fw"></i> ' + lang('loading')
  221. repository.listCategories({},
  222. (err, result) => {
  223. browser.dom.innerHTML = ''
  224. const ul = document.createElement('ul')
  225. browser.dom.appendChild(ul)
  226. result.forEach(cat => {
  227. const li = document.createElement('li')
  228. const a = document.createElement('a')
  229. a.href = '#categories=custom/' + cat.id
  230. a.appendChild(document.createTextNode(cat.name))
  231. li.appendChild(a)
  232. const edit = document.createElement('a')
  233. edit.onclick = (e) => {
  234. editCustomCategory(cat.id)
  235. e.preventDefault()
  236. }
  237. edit.innerHTML = ' <i class="fa fa-pen"></i>'
  238. li.appendChild(edit)
  239. ul.appendChild(li)
  240. })
  241. browser.catchLinks()
  242. })
  243. }
  244. const repository = new CustomCategoryRepository()
  245. hooks.register('init', () => {
  246. OpenStreetBrowserLoader.registerRepository('custom', repository)
  247. })
  248. hooks.register('category-overpass-init', (category) => {
  249. const m = category.id.match(/^custom\/(.*)$/)
  250. if (m) {
  251. const id = m[1]
  252. if (category.tabEdit) {
  253. category.tools.remove(this.category.tabEdit)
  254. }
  255. category.tabEdit = new tabs.Tab({
  256. id: 'edit',
  257. weight: 9
  258. })
  259. category.tools.add(category.tabEdit)
  260. category.tabEdit.header.innerHTML = '<i class="fa fa-pen"></i>'
  261. category.tabEdit.header.title = lang('edit')
  262. category.tabEdit.on('select', () => {
  263. category.tabEdit.unselect()
  264. editCustomCategory(id, category)
  265. })
  266. if (!category.tabShare) {
  267. const url = location.origin + location.pathname + '#categories=custom/' + id
  268. category.tabShare = new tabs.Tab({
  269. id: 'share',
  270. weight: 10
  271. })
  272. category.tools.add(category.tabShare)
  273. category.shareLink = document.createElement('a')
  274. category.shareLink.href = url
  275. category.shareLink.innerHTML = '<i class="fa fa-share-alt"></i>'
  276. category.tabShare.header.appendChild(category.shareLink)
  277. category.tabShare.header.className = 'share-button'
  278. category.tabShare.on('select', () => {
  279. category.tabShare.unselect()
  280. navigator.clipboard.writeText(url)
  281. const notify = document.createElement('div')
  282. notify.className = 'notify'
  283. notify.innerHTML = lang('copied-clipboard')
  284. category.tabShare.header.appendChild(notify)
  285. global.setTimeout(() => category.tabShare.header.removeChild(notify), 2000)
  286. })
  287. }
  288. } else {
  289. if (category.tabClone) {
  290. category.tools.remove(this.category.tabClone)
  291. }
  292. category.tabClone = new tabs.Tab({
  293. id: 'clone',
  294. weight: 9
  295. })
  296. category.tools.add(category.tabClone)
  297. category.tabClone.header.innerHTML = '<i class="fa fa-clone"></i>'
  298. category.tabClone.header.title = lang('customCategory:clone')
  299. category.tabClone.on('select', () => {
  300. category.tabClone.unselect()
  301. const clone = new CustomCategoryEditor(repository)
  302. clone.edit()
  303. category.repository.file_get_contents(category.data.fileName, {},
  304. (err, content) => {
  305. if (err) {
  306. console.error(err)
  307. return global.alert(err)
  308. }
  309. if (category.data.format === 'json') {
  310. content = JSON.parse(content)
  311. content = jsonMultilineStrings.join(content, { exclude: [['const'], ['filter']] })
  312. content = yaml.dump(content, {
  313. lineWidth: 9999
  314. })
  315. }
  316. clone.applyContent(content)
  317. category.close()
  318. }
  319. )
  320. })
  321. }
  322. })
  323. function customCategoryTest (value) {
  324. if (!value) {
  325. return new Error('Empty category')
  326. }
  327. let data
  328. try {
  329. data = yaml.load(value)
  330. } catch (e) {
  331. return e
  332. }
  333. if (!data || typeof data !== 'object') {
  334. return new Error('Data can not be parsed into an object')
  335. }
  336. if (!('query' in data)) {
  337. return new Error('No "query" defined!')
  338. }
  339. if (typeof data.query === 'string') {
  340. const r = customCategoryTestQuery(data.query)
  341. if (r) { return r }
  342. } else if (data.query === null) {
  343. return new Error('No "query" defined!')
  344. } else if (Object.values(data.query).length) {
  345. for (const z in data.query) {
  346. const r = customCategoryTestQuery(data.query[z])
  347. if (r) { return new Error('Query z' + z + ': ' + r) }
  348. }
  349. } else {
  350. return new Error('"query" can not be parsed!')
  351. }
  352. const testPaths = [
  353. [ /^(memberF|f)eature$/, /./ ],
  354. [ /^(memberF|f)eature$/, /^style(:.*)$/, /./ ],
  355. [ 'filter', /./, /(name|query|values|placeholder|valueName)/ ],
  356. [ 'filter', /./, 'values', /./ , 'name'],
  357. [ 'info' ]
  358. ]
  359. try {
  360. testPaths.forEach(path => customCategoryTestCompilePath(data, path, ''))
  361. }
  362. catch (err) {
  363. return err
  364. }
  365. }
  366. function customCategoryTestCompilePath (data, subPaths, path) {
  367. if (subPaths.length) {
  368. const p = subPaths[0]
  369. for (const k in data) {
  370. if (typeof p === 'string' ? k === p : k.match(p)) {
  371. customCategoryTestCompilePath(data[k], subPaths.slice(1), path + '/' + k)
  372. }
  373. }
  374. }
  375. const err = customCategoryTestCompile(data)
  376. if (err) {
  377. throw new Error('Compiling ' + path + ': ' + err.message)
  378. }
  379. }
  380. function customCategoryTestCompile (data) {
  381. if (typeof data !== 'string' || data.search('{') === -1) {
  382. return
  383. }
  384. let template
  385. try {
  386. template = OverpassLayer.twig.twig({ data, rethrow: true })
  387. } catch (e) {
  388. return e
  389. }
  390. const fakeOb = {
  391. id: 'n1',
  392. sublayer_id: 'main',
  393. osm_id: 1,
  394. type: 'node',
  395. tags: {
  396. foo: 'bar'
  397. },
  398. map: {
  399. zoom: 15,
  400. metersPerPixel: 0.8
  401. }
  402. }
  403. try {
  404. template.render(fakeOb)
  405. } catch (e) {
  406. return e
  407. }
  408. }
  409. function customCategoryTestQuery (str) {
  410. if (typeof str !== 'string') {
  411. return 'Query is not a string!'
  412. }
  413. // make sure the request ends with ';'
  414. if (!str.match(/;\s*$/)) {
  415. str += ';'
  416. }
  417. try {
  418. new OverpassFrontendFilter(str)
  419. } catch (e) {
  420. return e
  421. }
  422. }