diff --git a/README.md b/README.md index 67fe8be8..807fb3b0 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,9 @@ git clone https://github.com/plepe/openstreetbrowser-categories-main.git node_mo You are welcome to send pull requests via Github! ### Category definition -There are currently two types of categories: `index` (for sub categories) and `overpass` (for OpenStreetMap data, loaded via an Overpass API request). Each of them is defined via a JSON structure. They can be combined into a single file. +There are currently two types of categories: `index` (for sub categories) and `overpass` (for OpenStreetMap data, loaded via an Overpass API request). Each of them is defined via a JSON (old) or YAML (recommended) structure. They can be combined into a single file. + +Check out the [tutorial](./doc/Tutorial.md)! #### Category 'index' File: dir.json @@ -55,6 +57,16 @@ File: dir.json } ``` +or File: dir.yaml +```yaml +type: index +subCategories: + - id: foo + - id: bar + type: overpass + query: node[amenity=bar] +``` + This will define a category with the id 'dir' (from the file name) with two sub-categories: 'foo' (which will be loaded from the file `foo.json`) and 'bar' (which is defined inline as category of type 'overpass' and will show all nodes with the tag 'amenity' set to value 'bar' - see below for more details). #### Category 'overpass' diff --git a/ajax.php b/ajax.php index 0d50087e..f64e080b 100644 --- a/ajax.php +++ b/ajax.php @@ -17,9 +17,6 @@ function error($msg) { Header("Content-Type: application/json; charset=UTF-8"); $postdata = file_get_contents("php://input"); -if ($postdata) { - $postdata = json_decode($postdata, true); -} $fun = "ajax_{$_REQUEST['__func']}"; $return = $fun($_REQUEST, $postdata); diff --git a/conf.php-dist b/conf.php-dist index c9547d8d..29d0f399 100644 --- a/conf.php-dist +++ b/conf.php-dist @@ -95,6 +95,14 @@ $config['baseMaps'] = array( ), ); +// customCategory needs a database +$db_conf = [ + //'dsn' => 'mysql:host=localhost;dbname=openstreetbrowser', + 'dsn' => 'sqlite:data/db.sqlite', + 'username' => 'USERNAME', + 'password' => 'PASSWORD', +]; + // List of available user interface languages $languages = array( "en", // English diff --git a/customCategory.php b/customCategory.php new file mode 100644 index 00000000..03f1db50 --- /dev/null +++ b/customCategory.php @@ -0,0 +1,37 @@ + + + + + +list($_REQUEST); + + Header("Content-Type: application/json; charset=utf-8"); + print json_readable_encode($result); +} + +if (isset($_REQUEST['id'])) { + $category = $customCategoryRepository->getCategory($_REQUEST['id']); + if ($category) { + $customCategoryRepository->recordAccess($_REQUEST['id']); + } + + Header("Content-Type: application/yaml; charset=utf-8"); + Header("Content-Disposition: inline; filename=\"{$_REQUEST['id']}.yaml\""); + print $category; +} + +if (isset($_REQUEST['action']) && $_REQUEST['action'] === 'save') { + $content = file_get_contents("php://input"); + + $id = $customCategoryRepository->saveCategory($content); + $customCategoryRepository->recordAccess($id); + + Header("Content-Type: text/plain; charset=utf-8"); + print $id; +} diff --git a/doc/CategoryAsYAML.md b/doc/Tutorial.md similarity index 97% rename from doc/CategoryAsYAML.md rename to doc/Tutorial.md index 5e6596d1..e9f1b94a 100644 --- a/doc/CategoryAsYAML.md +++ b/doc/Tutorial.md @@ -128,6 +128,11 @@ feature: # each value individually. They are joined as enumeration. details: | {{ tagTransList('cuisine', tags.cuisine) }} + # Body is shown in the popup and the details in the sidebar. An easy way to + # show all tags is using the TwigJS 'yaml' filter, which produces YAML. + # Alternatively, you could use 'json_pp' (JSON pretty print). + body: | +
{{ tags|yaml }}
filter: cuisine: name: "{{ keyTrans('cuisine') }}" diff --git a/doc/TwigJS.md b/doc/TwigJS.md index 0e7e9982..8d52d56e 100644 --- a/doc/TwigJS.md +++ b/doc/TwigJS.md @@ -78,6 +78,9 @@ Extra filters: * filter `debug`: print the value (and further arguments) to the javascript console (via `console.log()`) * filter `wikipediaAbstract`: shows the abstract of a Wikipedia article in the selected data language (or, if not available, the language which was used in input, resp. 'en' for Wikidata input). Input is either 'language:article' (e.g. 'en:Douglas Adams') or a wikidata id (e.g. 'Q42'). * filter `wikidataEntity`: returns the wikidata entity in structured form (or `null` if the entity is not cached or `false` if it does not exist). Example: https://www.wikidata.org/wiki/Special:EntityData/Q42.json +* filter `json_pp`: JSON pretty print the object. As parameter to the filter, the following options can be passed: + * `indent`: indentation (default: 2) +* filter `yaml`: YAML pretty print the object. As options the filter, all options to [yaml.dump of js-yaml](https://github.com/nodeca/js-yaml#dump-object---options-) can be used. Notes: * Variables will automatically be HTML escaped, unless the filter raw is used, e.g.: `{{ tags.name|raw }}` diff --git a/init.sql b/init.sql new file mode 100644 index 00000000..5731376c --- /dev/null +++ b/init.sql @@ -0,0 +1,12 @@ +create table customCategory ( + id char(32) not null, + content mediumtext not null, + created datetime not null default CURRENT_TIMESTAMP, + primary key(id) +); + +create table customCategoryAccess ( + id char(32) not null, + ts datetime not null default CURRENT_TIMESTAMP, + foreign key(id) references customCategory(id) on delete cascade +); diff --git a/lang/en.json b/lang/en.json index a02708b6..5012e210 100644 --- a/lang/en.json +++ b/lang/en.json @@ -6,8 +6,18 @@ "cancel": "Cancel", "categories": "Categories", "category-info-tooltip": "Info & Map key", + "close": "Close", "closed": "closed", "default": "default", + "download": "Download", + "apply-keep": "Apply & keep editing", + "apply-close": "Apply & close", + "tip-tutorial": "Check out the [Tutorial]", + "customCategory:header": "Custom categories", + "customCategory:clone": "Clone as custom category", + "customCategory:create": "Create custom category", + "customCategory:list": "List popular custom categories", + "copied-clipboard": "Copied to clipboard", "edit": "edit", "editor:id": "iD (in-browser editor)", "editor:remote": "Remote Control (JOSM or Merkaator)", diff --git a/modulekit.php b/modulekit.php index 6bbce73a..6b3cf35f 100644 --- a/modulekit.php +++ b/modulekit.php @@ -12,6 +12,7 @@ $depend = array( $include = array( 'php' => array( 'src/defaults.php', + 'src/database.php', 'src/options.php', 'src/language.php', 'src/ip-location.php', @@ -22,6 +23,8 @@ $include = array( 'src/RepositoryDir.php', 'src/RepositoryGit.php', 'src/repositories.php', + 'src/repositoriesGitea.php', + 'src/customCategory.php', ), 'css' => array( 'style.css', diff --git a/package-lock.json b/package-lock.json index d5b06e8f..018ffe57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4819,7 +4819,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "requires": { "argparse": "^2.0.1" }, @@ -4827,8 +4826,7 @@ "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" } } }, @@ -7737,6 +7735,11 @@ "is-typed-array": "^1.1.6" } }, + "window-modal": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/window-modal/-/window-modal-1.0.5.tgz", + "integrity": "sha512-DbpgUeFeYLUoq/ZLCR2IESFxcf5koyKdtRgncumPCu9l/iu7lzGLOJon/XJZ/zRA1XEZv5Ga/rtOpldqF4bEjg==" + }, "word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", diff --git a/package.json b/package.json index df68799e..a19a8344 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "formatcoords": "^1.1.3", "i18next-client": "^1.11.4", "ip-location": "^1.0.1", + "js-yaml": "^4.1.0", "json-multiline-strings": "^0.1.0", "leaflet": "^1.7.1", "leaflet-geosearch": "^3.6.1", diff --git a/repo.php b/repo.php index 38f1b82f..c58ab485 100644 --- a/repo.php +++ b/repo.php @@ -26,6 +26,7 @@ if (!isset($_REQUEST['repo'])) { if (isset($repoData['categoryUrl'])) { $info['categoryUrl'] = $repoData['categoryUrl']; } + $info['group'] = $repoData['group'] ?? 'default'; print json_encode($info, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_FORCE_OBJECT); } @@ -59,6 +60,25 @@ if ($branchId) { } } +if (array_key_exists('file', $_REQUEST)) { + $file = $repo->file_get_contents($_REQUEST['file']); + + if ($file === false) { + Header("HTTP/1.1 403 Forbidden"); + print "Access denied."; + } + else if ($file === null) { + Header("HTTP/1.1 404 File not found"); + print "File not found."; + } + else { + Header("Content-Type: text/plain; charset=utf-8"); + print $file; + } + + exit(0); +} + $cacheDir = null; $ts = $repo->timestamp($path); if (isset($config['cache'])) { diff --git a/src/Browser.js b/src/Browser.js new file mode 100644 index 00000000..d372426e --- /dev/null +++ b/src/Browser.js @@ -0,0 +1,53 @@ +const EventEmitter = require('events') +const queryString = require('query-string') + +const domSort = require('./domSort') + +module.exports = class Browser extends EventEmitter { + constructor (id, dom) { + super() + + this.id = id + this.dom = dom + this.history = [] + } + + buildPage (parameters) { + this.clear() + + hooks.call('browser-' + this.id, this, parameters) + this.emit('buildPage', parameters) + this.parameters = parameters + + domSort(this.dom) + } + + clear () { + while (this.dom.lastChild) { + this.dom.removeChild(this.dom.lastChild) + } + } + + catchLinks () { + const links = this.dom.getElementsByTagName('a') + Array.from(links).forEach(link => { + const href = link.getAttribute('href') + + if (href && href.substr(0, this.id.length + 2) === '#' + this.id + '?') { + link.onclick = () => { + this.history.push(this.parameters) + + const parameters = queryString.parse(href.substr(this.id.length + 2)) + this.buildPage(parameters) + + return false + } + } + }) + } + + close () { + this.clear() + this.emit('close') + } +} diff --git a/src/OpenStreetBrowserLoader.js b/src/OpenStreetBrowserLoader.js index e8659ed9..11c4ef45 100644 --- a/src/OpenStreetBrowserLoader.js +++ b/src/OpenStreetBrowserLoader.js @@ -2,268 +2,282 @@ var OverpassLayer = require('overpass-layer') const Repository = require('./Repository') -function OpenStreetBrowserLoader () { - this.types = {} - this.categories = {} - this.repoCache = {} - this.repositories = {} - this.templates = {} - this._loadClash = {} // if a category is being loaded multiple times, collect callbacks -} - -OpenStreetBrowserLoader.prototype.setMap = function (map) { - this.map = map -} - -/** - * @param string id ID of the category - * @param [object] options Options. - * @waram {boolean} [options.force=false] Whether repository should be reload or not. - * @param function callback Callback which will be called with (err, category) - */ -OpenStreetBrowserLoader.prototype.getCategory = function (id, options, callback) { - if (typeof options === 'function') { - callback = options - options = {} +class OpenStreetBrowserLoader { + constructor () { + this.types = {} + this.categories = {} + this.repositories = {} + this.templates = {} + this._loadClash = {} // if a category is being loaded multiple times, collect callbacks } - var ids = this.getFullId(id, options) - if (ids === null) { - return callback(new Error('invalid id'), null) + setMap (map) { + this.map = map } - if (options.force) { - delete this.categories[ids.fullId] - } + /** + * @param string id ID of the category + * @param [object] options Options. + * @waram {boolean} [options.force=false] Whether repository should be reload or not. + * @param function callback Callback which will be called with (err, category) + */ + getCategory (id, options, callback) { + if (typeof options === 'function') { + callback = options + options = {} + } - if (ids.fullId in this.categories) { - return callback(null, this.categories[ids.fullId]) - } + var ids = this.getFullId(id, options) + if (ids === null) { + return callback(new Error('invalid id'), null) + } - var opt = JSON.parse(JSON.stringify(options)) - opt.categoryId = ids.entityId - opt.repositoryId = ids.repositoryId + if (options.force) { + delete this.categories[ids.fullId] + } - this.getRepo(ids.repositoryId, opt, function (err, repoData) { - // maybe loaded in the meantime? if (ids.fullId in this.categories) { return callback(null, this.categories[ids.fullId]) } - if (err) { - return callback(err, null) - } + var opt = JSON.parse(JSON.stringify(options)) + opt.categoryId = ids.entityId + opt.repositoryId = ids.repositoryId - if (!(ids.entityId in repoData.categories)) { - return callback(new Error('category "' + ids.entityId + '" not defined'), null) - } + this.getRepository(ids.repositoryId, opt, (err, repository) => { + // maybe loaded in the meantime? + if (ids.fullId in this.categories) { + return callback(null, this.categories[ids.fullId]) + } - this.getCategoryFromData(ids.id, opt, repoData.categories[ids.entityId], function (err, category) { - if (category) { - category.setMap(this.map) + if (err) { + return callback(err, null) } - callback(err, category) - }.bind(this)) - }.bind(this)) -} + repository.getCategory(ids.entityId, opt, (err, data) => { + // maybe loaded in the meantime? + if (ids.fullId in this.categories) { + return callback(null, this.categories[ids.fullId]) + } -/** - * @param string repo ID of the repository - * @parapm [object] options Options. - * @waram {boolean} [options.force=false] Whether repository should be reload or not. - * @param function callback Callback which will be called with (err, repoData) - */ -OpenStreetBrowserLoader.prototype.getRepo = function (repo, options, callback) { - if (options.force) { - delete this.repoCache[repo] - } + if (err) { return callback(err) } - if (repo in this.repoCache) { - return callback.apply(this, this.repoCache[repo]) - } + this.getCategoryFromData(ids.id, opt, data, (err, category) =>{ + if (category) { + category.setMap(this.map) + } - if (repo in this._loadClash) { - this._loadClash[repo].push(callback) - return + callback(err, category) + }) + }) + }) } - this._loadClash[repo] = [ callback ] - - function reqListener (req) { - if (req.status !== 200) { - console.log('http error when loading repository', req) - this.repoCache[repo] = [ req.statusText, null ] - } else { - try { - let repoData = JSON.parse(req.responseText) - this.repositories[repo] = new Repository(repo, repoData) - this.repoCache[repo] = [ null, repoData ] - } catch (err) { - console.log('couldn\'t parse repository', req.responseText) - this.repoCache[repo] = [ 'couldn\t parse repository', null ] + /** + * @param string repo ID of the repository + * @param [object] options Options. + * @param {boolean} [options.force=false] Whether repository should be reloaded or not. + * @param function callback Callback which will be called with (err, repository) + */ + getRepository (id, options, callback) { + if (id in this.repositories) { + const repository = this.repositories[id] + + if (repository.loadCallbacks) { + return repository.loadCallbacks.push((err) => callback(err, repository)) } - } - var todo = this._loadClash[repo] - delete this._loadClash[repo] + if (options.force) { + repository.clearCache() + return repository.load((err) => { + if (err) { return callback(err) } - todo.forEach(function (callback) { - callback.apply(this, this.repoCache[repo]) - }.bind(this)) - } + options.force = false + callback(repository.err, repository) + }) + } - var param = [] - if (repo) { - param.push('repo=' + encodeURIComponent(repo)) - } - param.push('lang=' + encodeURIComponent(ui_lang)) - param.push(config.categoriesRev) - param = param.length ? '?' + param.join('&') : '' - - var req = new XMLHttpRequest() - req.addEventListener('load', reqListener.bind(this, req)) - req.open('GET', 'repo.php' + param) - req.send() -} + return callback(repository.err, repository) + } -OpenStreetBrowserLoader.prototype.getRepository = function (id, options, callback) { - if (id in this.repositories) { - return callback(null, this.repositories[id]) + this.repositories[id] = new Repository(id) + this.repositories[id].load((err) => callback(err, this.repositories[id])) } - this.getRepo(id, options, (err, repoData) => { - if (err) { - return callback(err) + /** + * @param [object] options Options. + * @param {boolean} [options.force=false] Whether repository should be reloaded or not. + * @param function callback Callback which will be called with (err, list) + */ + getRepositoryList (options, callback) { + if (this.list) { + return callback(null, this.list) } - callback(null, this.repositories[id]) - }) -} + if (this.repositoryListCallbacks) { + return this.repositoryListCallbacks.push(callback) + } -/** - * @param string id ID of the template - * @parapm [object] options Options. - * @waram {boolean} [options.force=false] Whether repository should be reload or not. - * @param function callback Callback which will be called with (err, template) - */ -OpenStreetBrowserLoader.prototype.getTemplate = function (id, options, callback) { - if (typeof options === 'function') { - callback = options - options = {} + this.repositoryListCallbacks = [callback] + + var param = [] + param.push('lang=' + encodeURIComponent(ui_lang)) + param.push(config.categoriesRev) + param = param.length ? '?' + param.join('&') : '' + + fetch('repo.php' + param) + .then(res => res.json()) + .then(data => { + this.list = data + + global.setTimeout(() => { + const cbs = this.repositoryListCallbacks + this.repositoryListCallbacks = null + cbs.forEach(cb => cb(null, this.list)) + }, 0) + }) + .catch(err => { + global.setTimeout(() => { + const cbs = this.repositoryListCallbacks + this.repositoryListCallbacks = null + cbs.forEach(cb => cb(err)) + }, 0) + }) } - var ids = this.getFullId(id, options) - - if (options.force) { - delete this.templates[ids.fullId] - } + /** + * @param string id ID of the template + * @parapm [object] options Options. + * @waram {boolean} [options.force=false] Whether repository should be reload or not. + * @param function callback Callback which will be called with (err, template) + */ + getTemplate (id, options, callback) { + if (typeof options === 'function') { + callback = options + options = {} + } - if (ids.fullId in this.templates) { - return callback(null, this.templates[ids.fullId]) - } + var ids = this.getFullId(id, options) - var opt = JSON.parse(JSON.stringify(options)) - opt.templateId = ids.entityId - opt.repositoryId = ids.repositoryId + if (options.force) { + delete this.templates[ids.fullId] + } - this.getRepo(ids.repositoryId, opt, function (err, repoData) { - // maybe loaded in the meantime? if (ids.fullId in this.templates) { return callback(null, this.templates[ids.fullId]) } - if (err) { - return callback(err, null) - } + var opt = JSON.parse(JSON.stringify(options)) + opt.templateId = ids.entityId + opt.repositoryId = ids.repositoryId - if (!repoData.templates || !(ids.entityId in repoData.templates)) { - return callback(new Error('template not defined'), null) - } + this.getRepository(ids.repositoryId, opt, (err, repository) => { + // maybe loaded in the meantime? + if (ids.fullId in this.templates) { + return callback(null, this.templates[ids.fullId]) + } - this.templates[ids.fullId] = OverpassLayer.twig.twig({ data: repoData.templates[ids.entityId], autoescape: true }) + if (err) { + return callback(err, null) + } - callback(null, this.templates[ids.fullId]) - }.bind(this)) -} + repository.getTemplate(ids.entityId, opt, (err, data) => { + // maybe loaded in the meantime? + if (ids.fullId in this.templates) { + return callback(null, this.templates[ids.fullId]) + } -OpenStreetBrowserLoader.prototype.getCategoryFromData = function (id, options, data, callback) { - var ids = this.getFullId(id, options) + if (err) { return callback(err) } - if (ids.fullId in this.categories) { - return callback(null, this.categories[ids.fullId]) - } + this.templates[ids.fullId] = OverpassLayer.twig.twig({ data, autoescape: true }) - if (!data.type) { - return callback(new Error('no type defined'), null) + callback(null, this.templates[ids.fullId]) + }) + }) } - if (!(data.type in this.types)) { - return callback(new Error('unknown type'), null) - } + getCategoryFromData (id, options, data, callback) { + var ids = this.getFullId(id, options) - let repository = this.repositories[ids.repositoryId] + if (ids.fullId in this.categories) { + return callback(null, this.categories[ids.fullId]) + } - var opt = JSON.parse(JSON.stringify(options)) - opt.id = ids.id - var layer = new this.types[data.type](opt, data, repository) + if (!data.type) { + data.type = 'overpass' + } - layer.setMap(this.map) + if (!(data.type in this.types)) { + return callback(new Error('unknown type'), null) + } - this.categories[ids.fullId] = layer + let repository = this.repositories[ids.repositoryId] - if ('load' in layer) { - layer.load(function (err) { - callback(err, layer) - }) - } else { - callback(null, layer) - } -} + var opt = JSON.parse(JSON.stringify(options)) + opt.id = ids.id + var layer = new this.types[data.type](opt, data, repository) -OpenStreetBrowserLoader.prototype.getFullId = function (id, options) { - var result = {} + layer.setMap(this.map) - if (!id) { - return null - } + this.categories[ids.fullId] = layer - let m = id.match(/^(.*)\/([^/]*)/) - if (m) { - result.id = id - result.repositoryId = m[1] - result.entityId = m[2] - } else if (options.repositoryId && options.repositoryId !== 'default') { - result.repositoryId = options.repositoryId - result.entityId = id - result.id = result.repositoryId + '/' + id - } else { - result.id = id - result.repositoryId = 'default' - result.entityId = id + if ('load' in layer) { + layer.load(function (err) { + callback(err, layer) + }) + } else { + callback(null, layer) + } } - result.sublayerId = null - m = result.entityId.split(/:/) - if (m.length > 1) { - result.sublayerId = m[0] - result.entityId = m[1] - } + getFullId (id, options) { + var result = {} + + if (!id) { + return null + } - result.fullId = result.repositoryId + '/' + (result.sublayerId ? result.sublayerId + ':' : '') + result.entityId + let m = id.match(/^(.*)\/([^/]*)/) + if (m) { + result.id = id + result.repositoryId = m[1] + result.entityId = m[2] + } else if (options.repositoryId && options.repositoryId !== 'default') { + result.repositoryId = options.repositoryId + result.entityId = id + result.id = result.repositoryId + '/' + id + } else { + result.id = id + result.repositoryId = 'default' + result.entityId = id + } - return result -} + result.sublayerId = null + m = result.entityId.split(/:/) + if (m.length > 1) { + result.sublayerId = m[0] + result.entityId = m[1] + } + + result.fullId = result.repositoryId + '/' + (result.sublayerId ? result.sublayerId + ':' : '') + result.entityId + + return result + } -OpenStreetBrowserLoader.prototype.forget = function (id) { - var ids = this.getFullId(id, options) + forget (id) { + var ids = this.getFullId(id, options) - this.categories[ids.fullId].remove() - delete this.categories[ids.fullId] + this.categories[ids.fullId].remove() + delete this.categories[ids.fullId] + } + + registerType (type, classObject) { + this.types[type] = classObject + } } -OpenStreetBrowserLoader.prototype.registerType = function (type, classObject) { - this.types[type] = classObject +OpenStreetBrowserLoader.prototype.registerRepository = function (id, repository) { + this.repositories[id] = repository } module.exports = new OpenStreetBrowserLoader() diff --git a/src/Repository.js b/src/Repository.js index c5e7de30..9f951665 100644 --- a/src/Repository.js +++ b/src/Repository.js @@ -1,8 +1,90 @@ module.exports = class Repository { constructor (id, data) { this.id = id - this.data = data + this.isLoaded = false - this.lang = this.data.lang || {} + if (data) { + this.data = data + this.lang = this.data.lang || {} + this.loadCallbacks = null + } + } + + file_get_contents (fileName, options, callback) { + let param = [] + param.push('repo=' + encodeURIComponent(this.id)) + param.push('file=' + encodeURIComponent(fileName)) + param.push(config.categoriesRev) + param = param.length ? '?' + param.join('&') : '' + + fetch('repo.php' + param) + .then(res => res.text()) + .then(data => { + global.setTimeout(() => { + callback(null, data) + }, 0) + }) + .catch(err => { + global.setTimeout(() => { + callback(err) + }, 0) + }) + } + + load (callback) { + if (this.loadCallbacks) { + return this.loadCallbacks.push(callback) + } + + this.loadCallbacks = [callback] + + var param = [] + + param.push('repo=' + encodeURIComponent(this.id)) + param.push('lang=' + encodeURIComponent(ui_lang)) + param.push(config.categoriesRev) + param = param.length ? '?' + param.join('&') : '' + + fetch('repo.php' + param) + .then(res => res.json()) + .then(data => { + this.data = data + this.lang = this.data.lang || {} + this.err = null + + global.setTimeout(() => { + const cbs = this.loadCallbacks + this.loadCallbacks = null + cbs.forEach(cb => cb(null)) + }, 0) + }) + .catch(err => { + this.err = err + global.setTimeout(() => { + const cbs = this.loadCallbacks + this.loadCallbacks = null + cbs.forEach(cb => cb(err)) + }, 0) + }) + } + + clearCache () { + this.data = null + } + + getCategory (id, options, callback) { + if (!(id in this.data.categories)) { + return callback(new Error('Repository ' + this.id + ': Category "' + id + '" not defined'), null) + } + + callback(null, this.data.categories[id]) + } + + getTemplate (id, options, callback) { + if (!(id in this.data.templates)) { + return callback(new Error('Repository ' + this.id + ': Template "' + id + '" not defined'), null) + } + + callback(null, this.data.templates[id]) } } diff --git a/src/RepositoryDir.php b/src/RepositoryDir.php index 07e33667..769c59b8 100644 --- a/src/RepositoryDir.php +++ b/src/RepositoryDir.php @@ -34,6 +34,7 @@ class RepositoryDir extends RepositoryBase { if (preg_match("/^([0-9a-zA-Z_\-]+)\.json$/", $f, $m) && $f !== 'package.json') { $d1 = json_decode(file_get_contents("{$this->path}/{$f}"), true); $d1['format'] = 'json'; + $d1['fileName'] = $f; if (!$this->isCategory($d1)) { continue; @@ -45,6 +46,7 @@ class RepositoryDir extends RepositoryBase { if (preg_match("/^([0-9a-zA-Z_\-]+)\.yaml$/", $f, $m)) { $d1 = yaml_parse(file_get_contents("{$this->path}/{$f}")); $d1['format'] = 'yaml'; + $d1['fileName'] = $f; if (!$this->isCategory($d1)) { continue; @@ -80,13 +82,17 @@ class RepositoryDir extends RepositoryBase { function file_get_contents ($file) { if (substr($file, 0, 1) === '.' || preg_match("/\/\./", $file)) { - return null; + return false; } if (!$this->access($file)) { return false; } + if (!file_exists("{$this->path}/{$file}")) { + return null; + } + return file_get_contents("{$this->path}/{$file}"); } diff --git a/src/RepositoryGit.php b/src/RepositoryGit.php index 6a95c89e..536e9072 100644 --- a/src/RepositoryGit.php +++ b/src/RepositoryGit.php @@ -72,6 +72,7 @@ class RepositoryGit extends RepositoryBase { $d1 = json_decode(shell_exec("cd " . escapeShellArg($this->path) . "; git show {$this->branchEsc}:" . escapeShellArg($f)), true); $d1['format'] = 'json'; + $d1['fileName'] = $f; if (!$this->isCategory($d1)) { continue; @@ -86,6 +87,7 @@ class RepositoryGit extends RepositoryBase { $d1 = yaml_parse(shell_exec("cd " . escapeShellArg($this->path) . "; git show {$this->branchEsc}:" . escapeShellArg($f))); $d1['format'] = 'yaml'; + $d1['fileName'] = $f; if (!$this->isCategory($d1)) { continue; diff --git a/src/Window.js b/src/Window.js new file mode 100644 index 00000000..c4203411 --- /dev/null +++ b/src/Window.js @@ -0,0 +1,97 @@ +const EventEmitter = require('events') + +module.exports = class Window extends EventEmitter { + constructor (options) { + super() + + this.visible = false + this.dom = document.createElement('div') + this.dom.className = 'Window' + + this.header = document.createElement('div') + this.header.className = 'header' + this.header.innerHTML = options.title + this.dom.appendChild(this.header) + + this.closeBtn = document.createElement('div') + this.closeBtn.className = 'closeBtn' + this.closeBtn.title = lang('close') + this.closeBtn.onclick = (e) => { + this.close() + e.stopImmediatePropagation() + } + this.header.appendChild(this.closeBtn) + + this.content = document.createElement('div') + this.content.className = 'content' + this.dom.appendChild(this.content) + + dragElement(this.dom) + + this.dom.onclick = () => { + if (!this.visible) { return } + + const activeEl = document.activeElement + + if (document.body.lastElementChild !== this.dom) { + document.body.appendChild(this.dom) + activeEl.focus() + } + } + } + + show () { + this.visible = true + document.body.appendChild(this.dom) + this.emit('show') + } + + close () { + this.visible = false + document.body.removeChild(this.dom) + this.emit('close') + } +} + +// copied from https://www.w3schools.com/HOWTO/howto_js_draggable.asp +// Make the DIV element draggable: +function dragElement(elmnt) { + var pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0; + if (elmnt.firstChild) { + // if present, the header is where you move the DIV from: + elmnt.firstChild.onmousedown = dragMouseDown; + } else { + // otherwise, move the DIV from anywhere inside the DIV: + elmnt.onmousedown = dragMouseDown; + } + + function dragMouseDown(e) { + e = e || window.event; + e.preventDefault(); + // get the mouse cursor position at startup: + pos3 = e.clientX; + pos4 = e.clientY; + document.onmouseup = closeDragElement; + // call a function whenever the cursor moves: + document.onmousemove = elementDrag; + } + + function elementDrag(e) { + e = e || window.event; + e.preventDefault(); + // calculate the new cursor position: + pos1 = pos3 - e.clientX; + pos2 = pos4 - e.clientY; + pos3 = e.clientX; + pos4 = e.clientY; + // set the element's new position: + elmnt.style.top = (elmnt.offsetTop - pos2) + "px"; + elmnt.style.left = (elmnt.offsetLeft - pos1) + "px"; + } + + function closeDragElement() { + // stop moving when mouse button is released: + document.onmouseup = null; + document.onmousemove = null; + } +} diff --git a/src/addCategories.js b/src/addCategories.js index 1944f987..e832476a 100644 --- a/src/addCategories.js +++ b/src/addCategories.js @@ -4,31 +4,110 @@ require('./addCategories.css') const tabs = require('modulekit-tabs') const weightSort = require('weight-sort') +const state = require('./state') const OpenStreetBrowserLoader = require('./OpenStreetBrowserLoader') let tab -function addCategoriesShow (repo, options={}) { - let content = tab.content - let repoId - let branchId +function addCategoriesList (content, browser, options = {}) { + content.innerHTML = ' ' + lang('loading') - if (repo) { - [ repoId, branchId ] = repo.split(/~/) - } + OpenStreetBrowserLoader.getRepositoryList(options, function (err, repoData) { + if (err) { + return global.alert(err) + } + + var categoryUrl = null + if (repoData.categoryUrl) { + categoryUrl = OverpassLayer.twig.twig({ data: repoData.categoryUrl, autoescape: true }) + } + + var list = {} + + if (typeof repositoriesGitea === 'object' && repositoriesGitea.url) { + let a = document.createElement('a') + a.href = repositoriesGitea.url + a.target = '_blank' + a.innerHTML = lang('more_categories_gitea') + content.appendChild(a) + } + + list = weightSort(repoData, { + key: 'timestamp', + reverse: true + }) + + let menu = document.createElement('ul') + menu.className = 'menu' + content.appendChild(menu) + + let header = document.createElement('h3') + header.innerHTML = lang('repositories') + ':' + content.appendChild(header) + + while (content.lastChild) { + content.removeChild(content.lastChild) + } + + var ul = document.createElement('ul') + + for (var id in list) { + var data = list[id] + + var repositoryUrl = null + if (data.repositoryUrl) { + repositoryUrl = OverpassLayer.twig.twig({ data: data.repositoryUrl, autoescape: true }) + } + + var li = document.createElement('li') + + let a = document.createElement('a') + a.href = '#more-categories?id=' + id + + li.appendChild(a) + a.appendChild(document.createTextNode('name' in data ? lang(data.name) : id)) + + var editLink = null + if (repositoryUrl) { + editLink = document.createElement('a') + editLink.href = repositoryUrl.render({ repositoryId: id }) + } + if (editLink) { + editLink.className = 'source-code' + editLink.title = 'Show source code' + editLink.target = '_blank' + editLink.innerHTML = '' + li.appendChild(document.createTextNode(' ')) + li.appendChild(editLink) + } + + ul.appendChild(li) + } + + content.appendChild(ul) + browser.catchLinks() + }) +} + +function addCategoriesShow (repo, browser, options={}) { + const content = browser.dom + + let [ repoId, branchId ] = repo.split(/~/) if (!branchId) { branchId = 'master' } - content.innerHTML = '

' + lang('more_categories') + '

' + ' ' + lang('loading') + content.innerHTML = ' ' + lang('loading') - OpenStreetBrowserLoader.getRepo(repo, options, function (err, repoData) { + OpenStreetBrowserLoader.getRepository(repo, options, function (err, repository) { if (err) { - alert(err) + return global.alert(err) } - content.innerHTML = '

' + lang('more_categories') + '

' + const repoData = repository.data + + content.innerHTML = '' var categoryUrl = null if (repoData.categoryUrl) { @@ -37,66 +116,40 @@ function addCategoriesShow (repo, options={}) { var list = {} - if (repo) { - var backLink = document.createElement('a') - backLink.className = 'back' - backLink.href = '#' - backLink.innerHTML = ' ' - backLink.appendChild(document.createTextNode(lang('back'))) + var backLink = document.createElement('a') + backLink.className = 'back' + backLink.href = '#more-categories?' + backLink.innerHTML = ' ' + backLink.appendChild(document.createTextNode(lang('back'))) + content.appendChild(backLink) + browser.catchLinks() - backLink.onclick = function () { - addCategoriesShow() - return false - } - content.appendChild(backLink) - - let h = document.createElement('h2') - h.appendChild(document.createTextNode(repoId)) - content.appendChild(h) - - list = repoData.categories - } else { - if (typeof repositoriesGitea === 'object' && repositoriesGitea.url) { - let a = document.createElement('a') - a.href = repositoriesGitea.url - a.target = '_blank' - a.innerHTML = lang('more_categories_gitea') - content.appendChild(a) - } + let h = document.createElement('h2') + h.appendChild(document.createTextNode(repoId)) + content.appendChild(h) - list = weightSort(repoData, { - key: 'timestamp', - reverse: true - }) - } + list = repoData.categories let menu = document.createElement('ul') menu.className = 'menu' content.appendChild(menu) - if (repo) { - let li = document.createElement('li') - menu.appendChild(li) + let li = document.createElement('li') + menu.appendChild(li) - let text = document.createElement('a') - text.innerHTML = lang('repo-use-as-base') - text.href = '#repo=' + repo - text.onclick = addCategoriesHide - li.appendChild(text) - } + let text = document.createElement('a') + text.innerHTML = lang('repo-use-as-base') + text.href = '#repo=' + repo + text.onclick = () => browser.close() + li.appendChild(text) - if (repo) { - let li = document.createElement('li') - menu.appendChild(li) + li = document.createElement('li') + menu.appendChild(li) - let text = document.createElement('a') - text.innerHTML = lang('reload') - text.href = '#' - text.onclick = () => { - addCategoriesShow(repo, { force: true }) - } - li.appendChild(text) - } + text = document.createElement('a') + text.innerHTML = lang('reload') + text.href = '#more-categories?id=' + repo + '&force=true' + li.appendChild(text) if ('branches' in repoData) { let li = document.createElement('li') @@ -131,7 +184,7 @@ function addCategoriesShow (repo, options={}) { } let header = document.createElement('h3') - header.innerHTML = lang(repo ? 'categories' : 'repositories') + ':' + header.innerHTML = lang('categories') + ':' content.appendChild(header) var ul = document.createElement('ul') @@ -144,21 +197,11 @@ function addCategoriesShow (repo, options={}) { repositoryUrl = OverpassLayer.twig.twig({ data: data.repositoryUrl, autoescape: true }) } - var li = document.createElement('li') + li = document.createElement('li') let a = document.createElement('a') - if (repo) { - a.href = '#categories=' + (repo === 'default' ? '' : repo + '/') + id - a.onclick = function () { - addCategoriesHide() - } - } else { - a.href = '#' - a.onclick = function (id) { - addCategoriesShow(id) - return false - }.bind(this, id) - } + a.href = '#categories=' + (repo === 'default' ? '' : repo + '/') + id + a.onclick = () => browser.close() li.appendChild(a) a.appendChild(document.createTextNode('name' in data ? lang(data.name) : id)) @@ -185,28 +228,33 @@ function addCategoriesShow (repo, options={}) { } content.appendChild(ul) + browser.catchLinks() }) } -function addCategoriesHide () { - tab.unselect() -} +hooks.register('browser-more-categories', (browser, parameters) => { + const content = browser.dom -register_hook('init', function (callback) { - tab = new tabs.Tab({ - id: 'addCategories' - }) - global.tabs.add(tab) + if (!Object.keys(parameters).length) { + let block = document.createElement('div') + block.setAttribute('weight', 1) + content.appendChild(block) - tab.header.innerHTML = '' - tab.header.title = lang('more_categories') + let header = document.createElement('h4') + header.innerHTML = lang('repositories') + block.appendChild(header) - let initialized = false + let div = document.createElement('div') + block.appendChild(div) + addCategoriesList(div, browser, parameters) - tab.on('select', () => { - if (!initialized) { - addCategoriesShow() - initialized = true - } - }) + browser.catchLinks() + } + else if (parameters.id) { + addCategoriesShow(parameters.id, browser, parameters) + } + else if (parameters.repo || parameters.categories) { + state.apply(parameters) + browser.close() + } }) diff --git a/src/customCategory.js b/src/customCategory.js new file mode 100644 index 00000000..5cd30543 --- /dev/null +++ b/src/customCategory.js @@ -0,0 +1,464 @@ +const tabs = require('modulekit-tabs') +const yaml = require('js-yaml') +const md5 = require('md5') +const OverpassLayer = require('overpass-layer') +const jsonMultilineStrings = require('json-multiline-strings') + +const Window = require('./Window') +const OpenStreetBrowserLoader = require('./OpenStreetBrowserLoader') + +const editors = [] + +class CustomCategoryRepository { + constructor () { + this.clearCache() + } + + load (callback) { + callback(null) + } + + clearCache () { + this.cache = {} + } + + listCategories (options, callback) { + fetch('customCategory.php?action=list') + .then(res => res.json()) + .then(result => callback(null, result)) + } + + getCategory (id, options, callback) { + if (id in this.cache) { + const data = this.parseCategory(id, this.cache[id]) + return callback(null, data, this.cache[id]) + } + + fetch('customCategory.php?id=' + id) + .then(res => res.text()) + .then(content => { + this.cache[id] = content + + const data = this.parseCategory(id, content) + + callback(null, data, content) + }) + } + + parseCategory (id, content) { + let data + + try { + data = yaml.load(content) + } + catch (e) { + return global.alert(e) + } + + if (data && typeof data !== 'object') { + callback(new Error('Data can not be parsed into an object')) + } + + if (!data.name) { + data.name = 'Custom ' + id.substr(0, 6) + } + + return data + } + + saveCategory (body, options, callback) { + const id = md5(body) + this.cache[id] = body + + fetch('customCategory.php?action=save', { + method: 'POST', + body + }) + } + + getTemplate (id, options, callback) { + callback(null, '') + } +} + +class CustomCategoryEditor { + constructor (repository) { + this.repository = repository + editors.push(this) + } + + load (id, callback) { + this.repository.getCategory(id, {}, + (err, category, content) => { + this.content = content + callback(err, content) + }) + } + + edit () { + if (this.window) { + this.window.focused = true + return + } + + this.window = new Window({ + title: 'Custom Category' + }) + + this.window.on('close', () => { + this.window = null + }) + + this.textarea = document.createElement('textarea') + this.textarea.spellcheck = false + this.window.content.appendChild(this.textarea) + if (this.content !== undefined) { + this.textarea.value = this.content + } + + const controls = document.createElement('div') + controls.className = 'controls' + this.window.content.appendChild(controls) + + const input = document.createElement('input') + input.type = 'submit' + input.value = lang('apply-keep') + controls.appendChild(input) + + const inputClose = document.createElement('input') + inputClose.type = 'button' + inputClose.value = lang('apply-close') + inputClose.onclick = () => { + this.submit() + this.window.close() + } + controls.appendChild(inputClose) + + const icons = document.createElement('div') + icons.className = 'actions' + controls.appendChild(icons) + + this.inputDownload = document.createElement('a') + this.textarea.onchange = () => this.updateDownload() + this.updateDownload() + this.inputDownload.title = lang('download') + this.inputDownload.innerHTML = '' + icons.appendChild(this.inputDownload) + + const tutorial = document.createElement('span') + tutorial.className = 'tip-tutorial' + let text = lang('tip-tutorial') + text = text.replace('[', '') + text = text.replace(']', '') + tutorial.innerHTML = text + controls.appendChild(tutorial) + + input.onclick = (e) => { + this.submit() + e.preventDefault() + } + + this.window.show() + } + + submit () { + const err = customCategoryTest(this.textarea.value) + if (err) { + return global.alert(err) + } + + this.applyContent(this.textarea.value) + } + + applyContent (content) { + this.content = content + this.repository.saveCategory(this.content, {}, () => {}) + + if (this.textarea) { + this.textarea.value = content + this.updateDownload() + } + + const id = md5(content) + this.id = id + + if (this.category) { + this.category.remove() + this.category = null + } + + OpenStreetBrowserLoader.getCategory('custom/' + id, {}, (err, category) => { + if (err) { + return global.alert(err) + } + + this.category = category + this.category.setParentDom(document.getElementById('contentListAddCategories')) + + this.category.open() + }) + } + + updateDownload () { + const file = new Blob([this.textarea.value], { type: 'application/yaml' }) + this.inputDownload.href = URL.createObjectURL(file) + this.inputDownload.download = md5(this.textarea.value) + '.yaml' + } +} + + +function editCustomCategory (id, category) { + let done = editors.filter(editor => { + if (editor.id === id) { + editor.edit() + return true + } + }) + + if (!done.length) { + const editor = new CustomCategoryEditor(repository) + editor.load(id, (err) => { + if (err) { return global.alert(err) } + editor.category = category + editor.edit() + }) + } +} + +hooks.register('browser-more-categories', (browser, parameters) => { + const content = browser.dom + + if (!Object.keys(parameters).length) { + let block = document.createElement('div') + block.setAttribute('weight', 0) + content.appendChild(block) + + let header = document.createElement('h4') + header.innerHTML = lang('customCategory:header') + block.appendChild(header) + + let ul = document.createElement('ul') + + let li = document.createElement('li') + let a = document.createElement('a') + a.innerHTML = lang('customCategory:create') + a.href = '#' + a.onclick = (e) => { + const editor = new CustomCategoryEditor(repository) + editor.edit() + browser.close() + e.preventDefault() + } + li.appendChild(a) + ul.appendChild(li) + + li = document.createElement('li') + a = document.createElement('a') + a.innerHTML = lang('customCategory:list') + a.href = '#more-categories?custom=list' + li.appendChild(a) + ul.appendChild(li) + + block.appendChild(ul) + browser.catchLinks() + } + else if (parameters.custom === 'list') { + customCategoriesList(browser, parameters) + } +}) + +function customCategoriesList (browser, options) { + browser.dom.innerHTML = ' ' + lang('loading') + + repository.listCategories({}, + (err, result) => { + browser.dom.innerHTML = '' + + const ul = document.createElement('ul') + browser.dom.appendChild(ul) + + result.forEach(cat => { + const li = document.createElement('li') + + const a = document.createElement('a') + a.href = '#categories=custom/' + cat.id + a.appendChild(document.createTextNode(cat.name)) + li.appendChild(a) + + const edit = document.createElement('a') + edit.onclick = (e) => { + editCustomCategory(cat.id) + e.preventDefault() + } + edit.innerHTML = ' ' + li.appendChild(edit) + + ul.appendChild(li) + }) + + browser.catchLinks() + }) +} + +const repository = new CustomCategoryRepository() +hooks.register('init', () => { + OpenStreetBrowserLoader.registerRepository('custom', repository) +}) + +hooks.register('category-overpass-init', (category) => { + const m = category.id.match(/^custom\/(.*)$/) + if (m) { + const id = m[1] + + if (category.tabEdit) { + category.tools.remove(this.category.tabEdit) + } + + category.tabEdit = new tabs.Tab({ + id: 'edit', + weight: 9 + }) + category.tools.add(category.tabEdit) + category.tabEdit.header.innerHTML = '' + category.tabEdit.header.title = lang('edit') + category.tabEdit.on('select', () => { + category.tabEdit.unselect() + editCustomCategory(id, category) + }) + + if (!category.tabShare) { + const url = location.origin + location.pathname + '#categories=custom/' + id + + category.tabShare = new tabs.Tab({ + id: 'share', + weight: 10 + }) + category.tools.add(category.tabShare) + category.shareLink = document.createElement('a') + category.shareLink.href = url + category.shareLink.innerHTML = '' + + category.tabShare.header.appendChild(category.shareLink) + category.tabShare.header.className = 'share-button' + category.tabShare.on('select', () => { + category.tabShare.unselect() + navigator.clipboard.writeText(url) + + const notify = document.createElement('div') + notify.className = 'notify' + notify.innerHTML = lang('copied-clipboard') + category.tabShare.header.appendChild(notify) + global.setTimeout(() => category.tabShare.header.removeChild(notify), 2000) + }) + } + } else { + if (category.tabClone) { + category.tools.remove(this.category.tabClone) + } + + category.tabClone = new tabs.Tab({ + id: 'clone', + weight: 9 + }) + category.tools.add(category.tabClone) + category.tabClone.header.innerHTML = '' + category.tabClone.header.title = lang('customCategory:clone') + category.tabClone.on('select', () => { + category.tabClone.unselect() + + const clone = new CustomCategoryEditor(repository) + clone.edit() + + category.repository.file_get_contents(category.data.fileName, {}, + (err, content) => { + if (category.data.format === 'json') { + content = JSON.parse(content) + content = jsonMultilineStrings.join(content, { exclude: [ [ 'const' ], [ 'filter' ] ] }) + content = yaml.dump(content, { + lineWidth: 9999 + }) + } + + clone.applyContent(content) + category.close() + } + ) + }) + + } +}) + +function customCategoryTest (value) { + if (!value) { + return new Error('Empty category') + } + + let data + try { + data = yaml.load(value) + } + catch (e) { + return e + } + + if (!data || typeof data !== 'object') { + return new Error('Data can not be parsed into an object') + } + + const fields = ['feature', 'memberFeature'] + for (let i1 = 0; i1 < fields.length; i1++) { + const k1 = fields[i1] + if (data[k1]) { + for (k2 in data[k1]) { + const err = customCategoryTestCompile(data[k1][k2]) + if (err) { + return new Error('Compiling /' + k1 + '/' + k2 + ': ' + err.message) + } + + if (k2 === 'style' || k2.match(/^style:/)) { + for (const k3 in data[k1][k2]) { + const err = customCategoryTestCompile(data[k1][k2][k3]) + if (err) { + return new Error('Compiling /' + k1 + '/' + k2 + '/' + k3 + ': ' + err.message) + } + } + } + } + } + } +} + +function customCategoryTestCompile (data) { + if (typeof data !== 'string' || data.search('{') === -1) { + return + } + + let template + try { + template = OverpassLayer.twig.twig({ data }) + } + catch (e) { + return e + } + + const fakeOb = { + id: 'n1', + sublayer_id: 'main', + osm_id: 1, + type: 'node', + tags: { + foo: 'bar' + }, + map: { + zoom: 15, + metersPerPixel: 0.8 + } + } + + try { + template.render(fakeOb) + } + catch (e) { + return e + } +} diff --git a/src/customCategory.php b/src/customCategory.php new file mode 100644 index 00000000..a5987845 --- /dev/null +++ b/src/customCategory.php @@ -0,0 +1,99 @@ +prepare("select content from customCategory where id=:id"); + $stmt->bindValue(':id', $id, PDO::PARAM_STR); + if ($stmt->execute()) { + $row = $stmt->fetch(PDO::FETCH_ASSOC); + $result = $row['content']; + $stmt->closeCursor(); + + return $result; + } + } + + function recordAccess ($id) { + global $db; + + if (!isset($_SESSION['customCategoryAccess'])) { + $_SESSION['customCategoryAccess'] = []; + } + + // update access per session only once a day + if (array_key_exists($id, $_SESSION['customCategoryAccess']) && $_SESSION['customCategoryAccess'][$id] > time() - 86400) { + return; + } + + $_SESSION['customCategoryAccess'][$id] = time(); + + $stmt = $db->prepare("insert into customCategoryAccess (id) values (:id)"); + $stmt->bindValue(':id', $id); + $stmt->execute(); + } + + function saveCategory ($content) { + global $db; + + $id = md5($content); + + switch ($db->getAttribute(PDO::ATTR_DRIVER_NAME)) { + case 'mysql': + $sqlAction = "insert ignore"; + break; + case 'sqlite': + default: + $sqlAction = "insert or ignore"; + } + + $stmt = $db->prepare("{$sqlAction} into customCategory (id, content) values (:id, :content)"); + $stmt->bindValue(':id', $id, PDO::PARAM_STR); + $stmt->bindValue(':content', $content, PDO::PARAM_STR); + $result = $stmt->execute(); + + return $id; + } + + function list ($options=[]) { + global $db; + + // $sqlCalcAge: the age of the access in days + switch ($db->getAttribute(PDO::ATTR_DRIVER_NAME)) { + case 'mysql': + $sqlCalcAge = "datediff(now(), ts)"; + break; + case 'sqlite': + $sqlCalcAge = "julianday('now')-julianday(ts)"; + } + + // the popularity column counts every acess with declining value over time, + // it halves every year. + $stmt = $db->prepare("select customCategory.id, customCategory.created, customCategory.content, t.accessCount, t.popularity, t.lastAccess from customCategory left join (select id, count(id) accessCount, sum(1/(({$sqlCalcAge})/365.25+1)) popularity, max(ts) lastAccess from customCategoryAccess group by id) t on customCategory.id=t.id order by popularity desc, created desc limit 25"); + $stmt->execute(); + $data = $stmt->fetchAll(PDO::FETCH_ASSOC); + $data = array_map(function ($d) { + $d['popularity'] = (float)$d['popularity']; + $d['accessCount'] = (int)$d['accessCount']; + + $content = yaml_parse($d['content']); + if ($content && is_array($content) && array_key_exists('name', $content)) { + $d['name'] = lang($content['name']); + } + else { + $d['name'] = 'Custom ' . substr($d['id'], 0, 6); + } + + unset($d['content']); + return $d; + }, $data); + + $stmt->closeCursor(); + return $data; + } +} + +$customCategoryRepository = new CustomCategoryRepository(); diff --git a/src/database.php b/src/database.php new file mode 100644 index 00000000..c5b013b8 --- /dev/null +++ b/src/database.php @@ -0,0 +1,9 @@ + (a.getAttribute(attribute) || 0) - (b.getAttribute(attribute) || 0) + ) + + list.forEach(el => dom.appendChild(el)) +} diff --git a/src/index.js b/src/index.js index 8bb0a593..64e603c9 100644 --- a/src/index.js +++ b/src/index.js @@ -33,6 +33,7 @@ require('./markers') require('./categories') require('./wikipedia') require('./image') +require('./moreCategories') require('./addCategories') require('./permalink') //require('./leaflet-geo-search') @@ -42,6 +43,7 @@ require('./GeoInfo') require('./PluginMeasure') require('./PluginGeoLocate') require('./tagsDisplay-tag2link') +require('./customCategory') const ObjectDisplay = require('./ObjectDisplay') let currentObjectDisplay = null diff --git a/src/moreCategories.js b/src/moreCategories.js new file mode 100644 index 00000000..208801b5 --- /dev/null +++ b/src/moreCategories.js @@ -0,0 +1,35 @@ +const tabs = require('modulekit-tabs') + +const Browser = require('./Browser') + +let tab + +function moreCategoriesIndex () { + let content = tab.content + + content.innerHTML = '

' + lang('more_categories') + '

' + + const dom = document.createElement('div') + content.appendChild(dom) + + const browser = new Browser('more-categories', dom) + browser.buildPage({}) + + browser.on('close', () => tab.unselect()) +} + +register_hook('init', function (callback) { + tab = new tabs.Tab({ + id: 'moreCategories' + }) + global.tabs.add(tab) + + tab.header.innerHTML = '' + tab.header.title = lang('more_categories') + + tab.on('select', () => { + tab.content.innerHTML = '' + moreCategoriesIndex() + }) +}) + diff --git a/src/options.php b/src/options.php index 69153560..8999e230 100644 --- a/src/options.php +++ b/src/options.php @@ -1,5 +1,7 @@ "{$repositoriesGitea['path']}/{$f1}/{$f2}", - 'type' => 'git', - ); - - if (array_key_exists('url', $repositoriesGitea)) { - $r['repositoryUrl'] = "{$repositoriesGitea['url']}/{{ repositoryId }}"; - $r['categoryUrl'] = "{$repositoriesGitea['url']}/{{ repositoryId }}/src/branch/{{ branchId }}/{{ categoryId }}.{{ categoryFormat }}"; - } - - $result["{$f1}/{$f2id}"] = $r; - } - } - closedir($d2); - } - } - closedir($d1); - } + call_hooks("get-repositories", $result); return $result; } @@ -57,14 +30,3 @@ function getRepo ($repoId, $repoData) { return $repo; } - -register_hook('init', function () { - global $repositoriesGitea; - - if (isset($repositoriesGitea) && array_key_exists('url', $repositoriesGitea)) { - $d = array('repositoriesGitea' => array( - 'url' => $repositoriesGitea['url'], - )); - html_export_var($d); - } -}); diff --git a/src/repositoriesGitea.php b/src/repositoriesGitea.php new file mode 100644 index 00000000..e83bf5e1 --- /dev/null +++ b/src/repositoriesGitea.php @@ -0,0 +1,44 @@ + "{$repositoriesGitea['path']}/{$f1}/{$f2}", + 'type' => 'git', + 'group' => 'gitea', + ); + + if (array_key_exists('url', $repositoriesGitea)) { + $r['repositoryUrl'] = "{$repositoriesGitea['url']}/{{ repositoryId }}"; + $r['categoryUrl'] = "{$repositoriesGitea['url']}/{{ repositoryId }}/src/branch/{{ branchId }}/{{ categoryId }}.{{ categoryFormat }}"; + } + + $result["{$f1}/{$f2id}"] = $r; + } + } + closedir($d2); + } + } + closedir($d1); + } +}); + +register_hook('init', function () { + global $repositoriesGitea; + + if (isset($repositoriesGitea) && array_key_exists('url', $repositoriesGitea)) { + $d = array('repositoriesGitea' => array( + 'url' => $repositoriesGitea['url'], + )); + html_export_var($d); + } +}); diff --git a/src/tagTranslations.js b/src/tagTranslations.js index 9b06079f..e39eaec2 100644 --- a/src/tagTranslations.js +++ b/src/tagTranslations.js @@ -31,8 +31,8 @@ OverpassLayer.twig.extendFunction('repoTrans', function (str) { return str } - let lang = global.currentCategory.repository.lang - const format = str in lang ? lang[str] : str + const lang = global.currentCategory.repository.lang + const format = lang && str in lang ? lang[str] : str return vsprintf(format, Array.from(arguments).slice(1)) }) diff --git a/src/twigFunctions.js b/src/twigFunctions.js index 40584ee2..1d49c321 100644 --- a/src/twigFunctions.js +++ b/src/twigFunctions.js @@ -5,6 +5,7 @@ var osmParseDate = require('openstreetmap-date-parser') var osmFormatDate = require('openstreetmap-date-format') const natsort = require('natsort').default const md5 = require('md5') +const yaml = require('js-yaml') var md5cache = {} @@ -140,3 +141,36 @@ OverpassLayer.twig.extendFilter('debug', function (value, param) { console.log.apply(null, [ value, ...param ]) return value }) +OverpassLayer.twig.extendFilter('json_pp', function (value, param) { + const options = param[0] || {} + + if (value === 'undefined') { + return 'null' + } + + value = twigClear(value) + + return JSON.stringify(value, null, 'indent' in options ? ' '.repeat(options.indent) : ' ') +}) +OverpassLayer.twig.extendFilter('yaml', function (value, param) { + const options = param[0] || {} + + value = twigClear(value) + + return yaml.dump(value, options) +}) + +function twigClear (value) { + if (value === null || typeof value !== 'object') { + return value + } + + const v = {} + for (let k in value) { + if (k !== '_keys') { + v[k] = value[k] + } + } + + return v +} diff --git a/style.css b/style.css index 11161578..ede24e04 100644 --- a/style.css +++ b/style.css @@ -612,3 +612,82 @@ ul.overpass-layer-list > li > a > .content > .details { margin-bottom: 0.25em; background-color: #ffdfdf; } + +.Window > .content textarea { + height: 100%; + width: 100%; + resize: none; + box-sizing: border-box; +} +.Window > .content .controls { + flex-grow: 0; +} +.Window > .content .controls > .actions { + margin-left: 1em; + margin-right: 1em; + display: inline; +} + +/* Window */ +.Window { + position: absolute; + z-index: 99999; + background-color: #f1f1f1; + border: 1px solid #000000; + resize: both; + overflow: hidden; + width: min(60em, 80%); + height: min(30em, 60%); + left: 10%; + top: 10%; + + display: flex; + flex-direction: column; + align-content: stretch; +} +.Window > .header { + padding: 0.25em; + font-weight: bold; + cursor: move; + z-index: 100000; + background-color: #dfdfdf; + color: #000000; + flex-grow: 0; + position: relative; +} +.Window > .header > .closeBtn { +} +.Window > .header > .closeBtn::before { + font-family: "Font Awesome 5 Free"; + content: "\f00d"; + position: absolute; + right: 0.25em; + top: 0.25em; +} +.Window > .content { + height: 100%; + display: flex; + flex-direction: column; + align-content: stretch; +} +.tip-tutorial { + padding: 0.25em 0; + display: inline-block; +} +.tip-tutorial a { + text-decoration: underline; +} + +/* Copy to clipboard */ +.share-button { + position: relative; +} +.share-button > .notify { + position: absolute; + background: white; + border: 1px solid black; + border-radius: 0.25em; + width: 8em; + text-align: center; + z-index: 1; +}