diff --git a/app/svganim/strokes.py b/app/svganim/strokes.py index c01ca1b..c51829e 100644 --- a/app/svganim/strokes.py +++ b/app/svganim/strokes.py @@ -727,6 +727,9 @@ class Tag(NodeMixin): def __repr__(self): return f"" + + def __str__(self): + return RenderTree(self).by_attr('name') def get_color(self): if self.color is None and self.parent is not None: @@ -741,12 +744,18 @@ class Tag(NodeMixin): if t.id == tag_id: return t return None + + def toJson(self) -> str: + return JsonExporter(indent=2).export(self) + +def loadTagFromJson(string) -> Tag: + tree: Tag = JsonImporter(DictImporter(Tag)).import_(string) + return tree def getRootTag(file = 'www/tags.json') -> Tag: with open(file, 'r') as fp: tree: Tag = JsonImporter(DictImporter(Tag)).read(fp) - # print(tree.descendants) return tree # print(RenderTree(tree)) diff --git a/app/templates/index.html b/app/templates/index.html index 18f8c7a..429bf93 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -17,7 +17,7 @@ } #tags li { - + line-height: 1.5; list-style: none; } @@ -33,20 +33,31 @@ #tags li>div, #tags li.add-tag { - cursor: pointer + cursor: pointer; } #tags .tag-id-root>div { display: none; } - #tags li:hover>ul>li.add-tag { + /* #tags li:hover>ul>li.add-tag { visibility: visible; ; + } */ + #tags li.selected > ul>li.add-tag { + display: block; + } + + #tags > li > ul >li.add-tag { + display: block; + ; } + + + #tags .add-tag { - visibility: hidden; + display: none; ; color: lightgray; font-size: 80%; @@ -59,9 +70,30 @@ padding: 0; border: solid 1px black; border-radius: 2px; - vertical-align: bottom; + vertical-align: middle; margin-right: 10px; } + #tags li:hover > div > input.rm-tag{ + display: inline-block; + } + #tags li div{ + position:relative + } + #tags input.rm-tag:hover{ + color: red; + transform: rotate(20deg); + } + #tags input.rm-tag{ + /* display: none; */ + position:absolute; + right:0; + top:0; + padding:0; + background: none; + border: none; + color: white; + cursor:pointer; + } #tags .selected>div { background: lightblue diff --git a/app/webserver.py b/app/webserver.py index 0d5c4b3..8644638 100644 --- a/app/webserver.py +++ b/app/webserver.py @@ -1,6 +1,7 @@ import json import logging import os +import shutil import tornado.ioloop import tornado.web import tornado.websocket @@ -519,7 +520,34 @@ class TagsHandler(tornado.web.RequestHandler): self.index = index def get(self): - raise Exception('todo') + self.set_header("Content-Type", "application/json") + with open('www/tags.json', 'r') as fp: + # TODO: enrich with counts + self.write(fp.read()) + + def put(self): + # data = json.loads(self.request.body) + tree = svganim.strokes.loadTagFromJson(self.request.body) + logger.info(f"New tag tree:\n{tree}") + newTagsContent = tree.toJson() + # save at minute resolution + now = datetime.datetime.utcnow().isoformat(timespec='minutes') + + backup_dir = os.path.join(self.config.storage, 'tag_versions') + if not os.path.exists(backup_dir): + logger.warning(f"Creating tags backupdir {backup_dir}") + os.mkdir(backup_dir) + + bakfile = os.path.join(backup_dir, f'tags.{now}.json') + logger.info(f"Creating tags backup {bakfile}" ) + shutil.copyfile('www/tags.json', bakfile) + + with open('www/tags.json', 'w') as fp: + fp.write(newTagsContent) + + self.set_status(204) + # print() + class IndexHandler(tornado.web.RequestHandler): """Get annotation as svg""" @@ -603,6 +631,9 @@ class Server: (r"/index", IndexHandler, {"config": self.config, "index": self.index}), + (r"/tags.json", TagsHandler, + {"config": self.config, "index": self.index}), + (r"/(.*)", StaticFileWithHeaderHandler, {"path": self.web_root, 'default_filename': 'index.html'}), ], diff --git a/app/www/annotations.js b/app/www/annotations.js index da7b172..034b9cd 100644 --- a/app/www/annotations.js +++ b/app/www/annotations.js @@ -6,6 +6,8 @@ class AnnotationManager { this.tagsEl = this.rootEl.querySelector('#tags'); this.annotationsEl = this.rootEl.querySelector('#annotations'); + this.selectedAnnotations = []; + this.selectedTag = null; this.loadTags(tagsUrl); } @@ -33,6 +35,9 @@ class AnnotationManager { tagEl.addEventListener('click', (ev) => { this.selectTag(tag); }) + tagEl.addEventListener('dblclick', (ev) => { + this.renameTag(tag); + }) const colorEl = document.createElement('input'); colorEl.type = 'color'; @@ -42,6 +47,16 @@ class AnnotationManager { }); tagEl.prepend(colorEl) + const rmEl = document.createElement('input'); + rmEl.type = 'button'; + rmEl.classList.add('rm-tag'); + rmEl.value = '🗑'; + rmEl.addEventListener('click', (ev) => { + ev.stopPropagation(); + this.removeTag(tag); + }); + tagEl.appendChild(rmEl) + tagLiEl.classList.add('tag-id-' + tag.id); tagLiEl.appendChild(tagEl); @@ -70,14 +85,16 @@ class AnnotationManager { selectTag(tag) { this.clearSelectedTag(); - + this.selectedTag = tag; tag.menuLiEl.classList.add('selected'); this.loadAnnotationsForTag(tag) } clearSelectedTag() { + this.selectedTag = null; const selected = this.tagsEl.querySelectorAll('.selected'); selected.forEach((s) => s.classList.remove('selected')); + this.resetSelectedAnnotations() //TODO empty annotationEl this.annotationsEl.innerHTML = ""; } @@ -112,14 +129,33 @@ class AnnotationManager { buildAnnotationList() { this.annotationsEl.innerHTML = ""; + this.actionsEl = document.createElement('div'); + this.actionsEl.classList.add('annotations-actions') + + this.buildAnnotationActions(); + + this.annotationsEl.appendChild(this.actionsEl); + + const ulEl = document.createElement('ul'); this.annotations.forEach((annotation, idx) => { const liEl = document.createElement('li'); const imgEl = document.createElement('img'); const playerEl = document.createElement('div'); - const infoEl = document.createElement('div'); + const infoEl = document.createElement('span'); infoEl.classList.add('annotation-info'); + const selectEl = document.createElement('input'); + selectEl.type = 'checkbox'; + selectEl.id = 'select-'+annotation.id_hash; + selectEl.addEventListener('change', (ev) => { + if (ev.target.checked) { + this.addAnnotationToSelection(annotation); + } else { + this.removeAnnotationFromSelection(annotation); + } + }) + const tag = this.rootTag.find_by_id(annotation.tag); console.log(tag) @@ -140,6 +176,7 @@ class AnnotationManager { liEl.appendChild(imgEl); liEl.appendChild(playerEl); + liEl.appendChild(selectEl); liEl.appendChild(infoEl); ulEl.appendChild(liEl); @@ -149,6 +186,76 @@ class AnnotationManager { } + resetSelectedAnnotations() { + this.selectedAnnotations = []; + + this.annotationsEl.querySelectorAll("li input[type='checkbox']").forEach((box) => box.checked = false); + this.buildAnnotationActions() + } + + addAnnotationToSelection(annotation) { + if (this.selectedAnnotations.indexOf(annotation) === -1){ + this.selectedAnnotations.push(annotation); + } + this.annotationsEl.querySelector('#select-'+annotation.id_hash).checked = true; + this.buildAnnotationActions() + } + removeAnnotationFromSelection(annotation) { + if (this.selectedAnnotations.indexOf(annotation) !== -1){ + this.selectedAnnotations.splice(this.selectedAnnotations.indexOf(annotation), 1) + } + this.annotationsEl.querySelector('#select-'+annotation.id_hash).checked = true; + this.buildAnnotationActions() + + } + + buildAnnotationActions() { + if(!this.actionsEl || !this.annotations.length) return + this.actionsEl.innerHTML = ""; + + const selectAllLabelEl = document.createElement('label'); + selectAllLabelEl.innerText = `Select all` + const selectAllEl = document.createElement('input'); + selectAllEl.type = 'checkbox'; + selectAllEl.checked = this.annotations.length === this.selectedAnnotations.length; + // selectAllEl.innerText = `Select all ${this.annotations.length} items`; + selectAllEl.addEventListener('change', (ev) => { + if(ev.target.checked) { + this.annotations.forEach((a) => this.addAnnotationToSelection(a)); + } else { + this.resetSelectedAnnotations(); + } + }); + + selectAllLabelEl.appendChild(selectAllEl) + + this.actionsEl.appendChild(selectAllLabelEl) + + if(!this.selectedAnnotations.length) return; + + const moveLabelEl = document.createElement('label'); + moveLabelEl.innerText = `Change tag for ${this.selectedAnnotations.length} items` + const moveSelectEl = document.createElement('select'); + this.rootTag.descendants().forEach((tag, i) => { + const tagEl = document.createElement('option'); + tagEl.value = tag.id; + if(tag.id == this.selectedTag.id){ + tagEl.selected = true; + } + tagEl.innerHTML = tag.get_indented_name(); + moveSelectEl.appendChild(tagEl); + }); + moveSelectEl.addEventListener('change', (ev) => { + const tag = this.rootTag.find_by_id(ev.target.value); + console.log(tag); + this.moveSelectedAnnotations(tag); + }) + moveLabelEl.appendChild(moveSelectEl) + + + this.actionsEl.appendChild(moveLabelEl) + } + renameTag(tag) { // TODO: add button for this const name = prompt(`Rename tag '${tag.get_name()}':`); @@ -156,15 +263,25 @@ class AnnotationManager { tag.name = name; this.saveTags(); + this.buildTagList(); } removeTag(tag) { + if (!confirm(`Do you want to delete ${tag.get_name()}`)) { + return false; + } // TODO: add button for this const request = new Request("/tags/" + tag.id); return fetch(request) - .then(response => response.json()) + .then(response => { + if (response.status == 404) { + return [] // not existing tag surely has no annotations + } else { + return response.json() + } + }) .then(annotations => { - if(annotations.length) { + if (annotations.length) { alert(`Cannot remove '${tag.get_name()}', as it is used for ${annotations.length} annotations.`) } else { // TODO: remove tag @@ -172,8 +289,9 @@ class AnnotationManager { tag.parent = null; this.saveTags(); + this.buildTagList(); } - }); + }) } setTagColor(tag, color) { @@ -183,15 +301,22 @@ class AnnotationManager { this.saveTags(); } - moveSelectedAnnotations(newTag) { + moveSelectedAnnotations(tag) { // TODO: add button for this - throw new Error("Not implemented"); + alert(`This doesn't work yet! (move to tag ${tag.get_name()})`) } - saveTags() { + async saveTags() { const json = this.rootTag.export(); - // TODO: save to remote console.log('save', json) + const response = await fetch('/tags.json', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: json + }).catch((e) => alert('Something went wrong saving the tags')); + } } @@ -243,14 +368,19 @@ class Tag { } get_name() { - if (this.hasOwnProperty('name')) { + if (this.hasOwnProperty('name') && this.name !== null) { return this.name; } return this.id; } + get_indented_name() { + const name = this.get_name(); + return ' '.repeat((this.depth() - 1) * 2) + '- ' + name + } + get_color() { - if (this.hasOwnProperty('color')) { + if (this.hasOwnProperty('color') && this.color !== null) { return this.color; } if (this.parent !== null) { diff --git a/app/www/tags.json b/app/www/tags.json index 98452c1..e49ae52 100644 --- a/app/www/tags.json +++ b/app/www/tags.json @@ -1,116 +1,172 @@ { - "id": "root", - "children": [ + "id": "root", + "name": "root", + "color": null, + "description": "", + "children": [ + { + "id": "human-machine", + "name": "Human/machine Entanglements (Appearance/disappearance)", + "color": "#ffa348", + "description": "", + "children": [ { - "id": "human-machine", - "name": "Human/machine Entanglements (Appearance/disappearance)", - "color": "orange", - "children": [ - { - "id": "vision", - "name": "Vision", - "color": "orange" - }, - { - "id": "sound" - }, - { - "id": "behaviour" - }, - { - "id": "other-senses", - "name": "Other senses" - } - ] + "id": "vision", + "name": "Vision", + "color": null, + "description": "" }, { - "id": "tensions", - "name": "Tensions, contestations & problems", - "description": "Which problems are identified?, when do they become problems?", - "color": "gray" + "id": "sound", + "name": "sound", + "color": null, + "description": "" }, { - "id": "security", - "color": "blue", - "name": "Security & types of data", - "children": [ - { - "id": "definitions", - "description": "e.g. domain knowledge" - }, - { - "id": "input", - "description": "e.g. fake data" - } - ] + "id": "behaviour", + "name": "behaviour", + "color": null, + "description": "" }, { - "id": "actants", - "name": "Actants in relation", - "color": "pink", - "children": [ - { - "id": "algorithm" - }, - { - "id": "technologies" - }, - { - "id": "frt" - }, - { - "id": "cameras", - "name": "CCTV & camera's" - }, - { - "id": "entities", - "name": "Entities: people, institutions etc." - }, - { - "id": "positioning", - "name": "Positioning", - "description": "the positioning of a field/person/oneself in relation to others" - }, - { - "id": "inside-outside" - }, - { - "id": "public-private" - } - ] - }, - { - "id": "consequences", - "color": "green", - "children": [ - { - "id": "effects" - }, - { - "id": "future-imaginaries" - }, - { - "id": "speculations", - "description": "what is & what will/can be done." - }, - { - "id": "innovations" - } - ] - }, - { - "id": "hesitation", - "name": "Hesitations & corrections", - "color": "yellow" - }, - { - "id": "skip", - "color": "black" - }, - { - "id": "todo", - "name": "to do / interesting", - "color": "red" + "id": "other-senses", + "name": "Other senses", + "color": null, + "description": "" } - ] + ] + }, + { + "id": "tensions", + "name": "Tensions, contestations & problems", + "color": "#77767b", + "description": "Which problems are identified?, when do they become problems?" + }, + { + "id": "security", + "name": "Security & types of data", + "color": "#3584e4", + "description": "", + "children": [ + { + "id": "definitions", + "name": "definitions", + "color": null, + "description": "e.g. domain knowledge" + }, + { + "id": "input", + "name": "input", + "color": null, + "description": "e.g. fake data" + } + ] + }, + { + "id": "actants", + "name": "Actants in relation", + "color": "#fa08ff", + "description": "", + "children": [ + { + "id": "algorithm", + "name": "algorithm", + "color": null, + "description": "" + }, + { + "id": "technologies", + "name": "technologies", + "color": null, + "description": "" + }, + { + "id": "frt", + "name": "frt", + "color": null, + "description": "" + }, + { + "id": "cameras", + "name": "CCTV & camera's", + "color": null, + "description": "" + }, + { + "id": "entities", + "name": "Entities: people, institutions etc.", + "color": null, + "description": "" + }, + { + "id": "positioning", + "name": "Positioning", + "color": null, + "description": "the positioning of a field/person/oneself in relation to others" + }, + { + "id": "inside-outside", + "name": "inside-outside", + "color": null, + "description": "" + }, + { + "id": "public-private", + "name": "public-private", + "color": null, + "description": "" + } + ] + }, + { + "id": "consequences", + "name": "consequences", + "color": "#0add32", + "description": "", + "children": [ + { + "id": "effects", + "name": "effects", + "color": null, + "description": "" + }, + { + "id": "future-imaginaries", + "name": "future-imaginaries", + "color": null, + "description": "" + }, + { + "id": "speculations", + "name": "speculations", + "color": null, + "description": "what is & what will/can be done." + }, + { + "id": "innovations", + "name": "innovations", + "color": null, + "description": "" + } + ] + }, + { + "id": "hesitation", + "name": "Hesitations & corrections", + "color": "#f8e45c", + "description": "" + }, + { + "id": "skip", + "name": "skip", + "color": null, + "description": "" + }, + { + "id": "todo", + "name": "to do / interesting", + "color": "#ff0000", + "description": "" + } + ] } \ No newline at end of file