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.

498 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. this.submit()
  106. this.window.close()
  107. }
  108. controls.appendChild(inputClose)
  109. const icons = document.createElement('div')
  110. icons.className = 'actions'
  111. controls.appendChild(icons)
  112. this.inputDownload = document.createElement('a')
  113. this.textarea.onchange = () => this.updateDownload()
  114. this.updateDownload()
  115. this.inputDownload.title = lang('download')
  116. this.inputDownload.innerHTML = '<i class="fas fa-download"></i>'
  117. icons.appendChild(this.inputDownload)
  118. const tutorial = document.createElement('span')
  119. tutorial.className = 'tip-tutorial'
  120. let text = lang('tip-tutorial')
  121. text = text.replace('[', '<a target="_blank" href="https://github.com/plepe/OpenStreetBrowser/blob/master/doc/Tutorial.md">')
  122. text = text.replace(']', '</a>')
  123. tutorial.innerHTML = text
  124. controls.appendChild(tutorial)
  125. input.onclick = (e) => {
  126. this.submit()
  127. e.preventDefault()
  128. }
  129. this.window.show()
  130. }
  131. submit () {
  132. const err = customCategoryTest(this.textarea.value)
  133. if (err) {
  134. return global.alert(err)
  135. }
  136. this.applyContent(this.textarea.value)
  137. }
  138. applyContent (content) {
  139. this.content = content
  140. this.repository.saveCategory(this.content, {}, () => {})
  141. if (this.textarea) {
  142. this.textarea.value = content
  143. this.updateDownload()
  144. }
  145. const id = md5(content)
  146. this.id = id
  147. if (this.category) {
  148. this.category.remove()
  149. this.category = null
  150. }
  151. OpenStreetBrowserLoader.getCategory('custom/' + id, {}, (err, category) => {
  152. if (err) {
  153. return global.alert(err)
  154. }
  155. this.category = category
  156. this.category.setParentDom(document.getElementById('contentListAddCategories'))
  157. this.category.open()
  158. })
  159. }
  160. updateDownload () {
  161. const file = new Blob([this.textarea.value], { type: 'application/yaml' })
  162. this.inputDownload.href = URL.createObjectURL(file)
  163. this.inputDownload.download = md5(this.textarea.value) + '.yaml'
  164. }
  165. }
  166. function editCustomCategory (id, category) {
  167. const done = editors.filter(editor => {
  168. if (editor.id === id) {
  169. editor.edit()
  170. return true
  171. }
  172. })
  173. if (!done.length) {
  174. const editor = new CustomCategoryEditor(repository)
  175. editor.load(id, (err) => {
  176. if (err) { return global.alert(err) }
  177. editor.category = category
  178. editor.edit()
  179. })
  180. }
  181. }
  182. hooks.register('browser-more-categories', (browser, parameters) => {
  183. const content = browser.dom
  184. if (!Object.keys(parameters).length) {
  185. const block = document.createElement('div')
  186. block.setAttribute('weight', 0)
  187. content.appendChild(block)
  188. const header = document.createElement('h4')
  189. header.innerHTML = lang('customCategory:header')
  190. block.appendChild(header)
  191. const ul = document.createElement('ul')
  192. let li = document.createElement('li')
  193. let a = document.createElement('a')
  194. a.innerHTML = lang('customCategory:create')
  195. a.href = '#'
  196. a.onclick = (e) => {
  197. const editor = new CustomCategoryEditor(repository)
  198. editor.edit()
  199. browser.close()
  200. e.preventDefault()
  201. }
  202. li.appendChild(a)
  203. ul.appendChild(li)
  204. li = document.createElement('li')
  205. a = document.createElement('a')
  206. a.innerHTML = lang('customCategory:list')
  207. a.href = '#more-categories?custom=list'
  208. li.appendChild(a)
  209. ul.appendChild(li)
  210. block.appendChild(ul)
  211. browser.catchLinks()
  212. } else if (parameters.custom === 'list') {
  213. customCategoriesList(browser, parameters)
  214. }
  215. })
  216. function customCategoriesList (browser, options) {
  217. browser.dom.innerHTML = '<i class="fa fa-spinner fa-pulse fa-fw"></i> ' + lang('loading')
  218. repository.listCategories({},
  219. (err, result) => {
  220. browser.dom.innerHTML = ''
  221. const ul = document.createElement('ul')
  222. browser.dom.appendChild(ul)
  223. result.forEach(cat => {
  224. const li = document.createElement('li')
  225. const a = document.createElement('a')
  226. a.href = '#categories=custom/' + cat.id
  227. a.appendChild(document.createTextNode(cat.name))
  228. li.appendChild(a)
  229. const edit = document.createElement('a')
  230. edit.onclick = (e) => {
  231. editCustomCategory(cat.id)
  232. e.preventDefault()
  233. }
  234. edit.innerHTML = ' <i class="fa fa-pen"></i>'
  235. li.appendChild(edit)
  236. ul.appendChild(li)
  237. })
  238. browser.catchLinks()
  239. })
  240. }
  241. const repository = new CustomCategoryRepository()
  242. hooks.register('init', () => {
  243. OpenStreetBrowserLoader.registerRepository('custom', repository)
  244. })
  245. hooks.register('category-overpass-init', (category) => {
  246. const m = category.id.match(/^custom\/(.*)$/)
  247. if (m) {
  248. const id = m[1]
  249. if (category.tabEdit) {
  250. category.tools.remove(this.category.tabEdit)
  251. }
  252. category.tabEdit = new tabs.Tab({
  253. id: 'edit',
  254. weight: 9
  255. })
  256. category.tools.add(category.tabEdit)
  257. category.tabEdit.header.innerHTML = '<i class="fa fa-pen"></i>'
  258. category.tabEdit.header.title = lang('edit')
  259. category.tabEdit.on('select', () => {
  260. category.tabEdit.unselect()
  261. editCustomCategory(id, category)
  262. })
  263. if (!category.tabShare) {
  264. const url = location.origin + location.pathname + '#categories=custom/' + id
  265. category.tabShare = new tabs.Tab({
  266. id: 'share',
  267. weight: 10
  268. })
  269. category.tools.add(category.tabShare)
  270. category.shareLink = document.createElement('a')
  271. category.shareLink.href = url
  272. category.shareLink.innerHTML = '<i class="fa fa-share-alt"></i>'
  273. category.tabShare.header.appendChild(category.shareLink)
  274. category.tabShare.header.className = 'share-button'
  275. category.tabShare.on('select', () => {
  276. category.tabShare.unselect()
  277. navigator.clipboard.writeText(url)
  278. const notify = document.createElement('div')
  279. notify.className = 'notify'
  280. notify.innerHTML = lang('copied-clipboard')
  281. category.tabShare.header.appendChild(notify)
  282. global.setTimeout(() => category.tabShare.header.removeChild(notify), 2000)
  283. })
  284. }
  285. } else {
  286. if (category.tabClone) {
  287. category.tools.remove(this.category.tabClone)
  288. }
  289. category.tabClone = new tabs.Tab({
  290. id: 'clone',
  291. weight: 9
  292. })
  293. category.tools.add(category.tabClone)
  294. category.tabClone.header.innerHTML = '<i class="fa fa-clone"></i>'
  295. category.tabClone.header.title = lang('customCategory:clone')
  296. category.tabClone.on('select', () => {
  297. category.tabClone.unselect()
  298. const clone = new CustomCategoryEditor(repository)
  299. clone.edit()
  300. category.repository.file_get_contents(category.data.fileName, {},
  301. (err, content) => {
  302. if (err) {
  303. console.error(err)
  304. return global.alert(err)
  305. }
  306. if (category.data.format === 'json') {
  307. content = JSON.parse(content)
  308. content = jsonMultilineStrings.join(content, { exclude: [['const'], ['filter']] })
  309. content = yaml.dump(content, {
  310. lineWidth: 9999
  311. })
  312. }
  313. clone.applyContent(content)
  314. category.close()
  315. }
  316. )
  317. })
  318. }
  319. })
  320. function customCategoryTest (value) {
  321. if (!value) {
  322. return new Error('Empty category')
  323. }
  324. let data
  325. try {
  326. data = yaml.load(value)
  327. } catch (e) {
  328. return e
  329. }
  330. if (!data || typeof data !== 'object') {
  331. return new Error('Data can not be parsed into an object')
  332. }
  333. if (!('query' in data)) {
  334. return new Error('No "query" defined!')
  335. }
  336. if (typeof data.query === 'string') {
  337. const r = customCategoryTestQuery(data.query)
  338. if (r) { return r }
  339. } else if (data.query === null) {
  340. return new Error('No "query" defined!')
  341. } else if (Object.values(data.query).length) {
  342. for (const z in data.query) {
  343. const r = customCategoryTestQuery(data.query[z])
  344. if (r) { return new Error('Query z' + z + ': ' + r) }
  345. }
  346. } else {
  347. return new Error('"query" can not be parsed!')
  348. }
  349. const fields = ['feature', 'memberFeature']
  350. for (let i1 = 0; i1 < fields.length; i1++) {
  351. const k1 = fields[i1]
  352. if (data[k1]) {
  353. for (const k2 in data[k1]) {
  354. const err = customCategoryTestCompile(data[k1][k2])
  355. if (err) {
  356. return new Error('Compiling /' + k1 + '/' + k2 + ': ' + err.message)
  357. }
  358. if (k2 === 'style' || k2.match(/^style:/)) {
  359. for (const k3 in data[k1][k2]) {
  360. const err = customCategoryTestCompile(data[k1][k2][k3])
  361. if (err) {
  362. return new Error('Compiling /' + k1 + '/' + k2 + '/' + k3 + ': ' + err.message)
  363. }
  364. }
  365. }
  366. }
  367. }
  368. }
  369. }
  370. function customCategoryTestCompile (data) {
  371. if (typeof data !== 'string' || data.search('{') === -1) {
  372. return
  373. }
  374. let template
  375. try {
  376. template = OverpassLayer.twig.twig({ data, rethrow: true })
  377. } catch (e) {
  378. return e
  379. }
  380. const fakeOb = {
  381. id: 'n1',
  382. sublayer_id: 'main',
  383. osm_id: 1,
  384. type: 'node',
  385. tags: {
  386. foo: 'bar'
  387. },
  388. map: {
  389. zoom: 15,
  390. metersPerPixel: 0.8
  391. }
  392. }
  393. try {
  394. template.render(fakeOb)
  395. } catch (e) {
  396. return e
  397. }
  398. }
  399. function customCategoryTestQuery (str) {
  400. if (typeof str !== 'string') {
  401. return 'Query is not a string!'
  402. }
  403. // make sure the request ends with ';'
  404. if (!str.match(/;\s*$/)) {
  405. str += ';'
  406. }
  407. try {
  408. new OverpassFrontendFilter(str)
  409. } catch (e) {
  410. return e
  411. }
  412. }