Compare commits

...

5 commits

Author SHA1 Message Date
Ruben van de Ven
adce8d067c Counts for tags in index 2022-06-08 18:51:22 +02:00
Ruben van de Ven
4b0b5c2c16 Feature: move annotations from one tag to another 2022-06-08 18:22:04 +02:00
Ruben van de Ven
5fa5096c44 WIP upgraded index, slash annotation editor 2022-06-08 17:26:30 +02:00
Ruben van de Ven
ab9c66794c Start with tag management. Incl. fancier index 2022-06-08 13:28:36 +02:00
Ruben van de Ven
232903c0ff WIP annotations 2022-06-01 16:08:49 +02:00
9 changed files with 969 additions and 165 deletions

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import copy
from ctypes.wintypes import tagMSG
import json
from os import X_OK, PathLike
import os
@ -12,6 +13,9 @@ import svgwrite
import tempfile
import io
import logging
from anytree import NodeMixin, RenderTree, iterators
from anytree.exporter import JsonExporter, DictExporter
from anytree.importer import JsonImporter, DictImporter
logger = logging.getLogger('svganim.strokes')
@ -19,11 +23,12 @@ Milliseconds = float
Seconds = float
class Annotation:
def __init__(self, tag: str, drawing: Drawing, t_in: Milliseconds, t_out: Milliseconds) -> None:
def __init__(self, tag: str, drawing: Drawing, t_in: Milliseconds, t_out: Milliseconds, comment: str = None) -> None:
self.tag = tag
self.t_in = t_in
self.t_out = t_out
self.drawing = drawing
self.comment = comment
@property
def id(self) -> str:
@ -232,7 +237,7 @@ class AnimationSlice:
strokes = self.getStrokeSlices(frame_in, frame_out, t_in)
# TODO shift t of points with t_in
viewboxes = self.getViewboxesSlice(t_in, t_out)
audio = self.audio.getSlice(t_in, t_out) if self.audio else None
return AnimationSlice([self.id[0], t_in, t_out], strokes, viewboxes, t_in, t_out, audio)
@ -499,6 +504,8 @@ class AnnotationIndex:
self.drawing_dir = drawing_dir
self.metadata_dir = metadata_dir
self.root_tag = getRootTag()
# disable disk cache because of glitches shelve.open(filename, writeback=True)
self.shelve = {}
@ -515,7 +522,9 @@ class AnnotationIndex:
Drawing(fn, self.metadata_dir, self.drawing_dir) for fn in self.get_drawing_filenames()
]
}
self.shelve['_tags'] = {}
self.root_tag = getRootTag()
self.shelve['_tags'] = {tag.id: [] for tag in self.root_tag.descendants}
self.shelve['_annotations'] = {}
drawing: Drawing
@ -525,14 +534,19 @@ class AnnotationIndex:
continue
for ann in meta['annotations']:
annotation = Annotation(
ann['tag'], drawing, ann['t_in'], ann['t_out'])
ann['tag'], drawing, ann['t_in'], ann['t_out'], ann['comment'] if 'comment' in ann else "")
self.shelve['_annotations'][annotation.id] = annotation
if annotation.tag not in self.shelve['_tags']:
self.shelve['_tags'][annotation.tag] = [annotation]
logger.error(f"Use of non-existing tag {annotation.tag}")
else:
self.shelve['_tags'][annotation.tag].append(
annotation
)
tag = self.root_tag.find_by_id(annotation.tag)
if tag is not None:
tag.annotation_count += 1
@property
def drawings(self) -> dict[str, Drawing]:
@ -545,11 +559,14 @@ class AnnotationIndex:
@property
def annotations(self) -> dict[str, Annotation]:
return self.shelve["_annotations"]
def has_tag(self, tag):
return tag in self.tags
def get_annotations(self, tag) -> list[Annotation]:
if tag not in self.tags:
def get_annotations_for_tag(self, tag_id) -> list[Annotation]:
if tag_id not in self.tags:
return []
return self.tags[tag]
return self.tags[tag_id]
def get_drawing_names(self) -> list[str]:
return [
@ -563,6 +580,14 @@ class AnnotationIndex:
os.path.join(self.drawing_dir, f"{name}.json_appendable")
for name in self.get_drawing_names()
]
def get_nested_annotations_for_tag(self, tag_id) -> list[Annotation]:
tag = self.root_tag.find_by_id(tag_id)
annotations = []
for tag in tag.descendants_incl_self():
annotations.extend(self.get_annotations_for_tag(tag.id))
return annotations
def __del__(self):
self.shelve.close()
@ -693,3 +718,56 @@ def strokes2D(strokes):
d += f"{rel_stroke[0]},{rel_stroke[1]} "
last_stroke = stroke
return d
class Tag(NodeMixin):
def __init__(self, id, name = None, description = "", color = None, parent=None, children=None, annotation_count=None):
self.id = id
self.name = self.id if name is None else name
self.color = color
self.description = description
self.parent = parent
if children:
self.children = children
self.annotation_count = 0 #always zero!
if self.id == 'root' and not self.is_root:
logger.error("Root node shouldn't have a parent assigned")
def __repr__(self):
return f"<svganim.strokes.Tag {self.id}>"
def __str__(self):
return RenderTree(self).by_attr('name')
def get_color(self):
if self.color is None and self.parent is not None:
return self.parent.get_color()
return self.color
def descendants_incl_self(self):
return tuple(iterators.PreOrderIter(self))
def find_by_id(self, tag_id) -> Optional[Tag]:
for t in self.descendants:
if t.id == tag_id:
return t
return None
def toJson(self, with_counts=False) -> str:
ignore_counts=lambda attrs: [(k, v) for k, v in attrs if k != "annotation_count"]
attrFilter = None if with_counts else ignore_counts
return JsonExporter(DictExporter(attriter=attrFilter), 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)
return tree
# print(RenderTree(tree))

View file

@ -1,7 +1,7 @@
from hashlib import md5
def annotation_hash(handler, input):
def annotation_hash(handler=None, input =""):
return md5(input.encode()).hexdigest()
# def nmbr(handler, lst) -> int:

View file

@ -10,22 +10,112 @@
color: white
}
ul {
#tags {
font-size: 80%;
padding: 0;
margin: 0;
}
#tags li {
line-height: 1.5;
list-style: none;
}
#tags .tag-id-root {
list-style: none;
;
}
#tags .tag-id-root>ul {
margin: 0;
padding: 0;
}
li {
display: inline-block;
#tags li>div,
#tags li.add-tag {
cursor: pointer;
}
#tags .tag-id-root>div {
display: none;
}
/* #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;
;
}
summary h2{
#annotations .annotations-actions {
margin-bottom: 10px;
color: darkgray;
font-size: 80%;
border-bottom: solid 3px #444;
}
#tags .add-tag {
display: none;
;
color: lightgray;
font-size: 80%;
}
#tags input[type="color"] {
cursor: pointer;
width: 15px;
height: 15px;
padding: 0;
border: solid 1px black;
border-radius: 2px;
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
}
summary h2 {
display: inline-block;
cursor: pointer;
}
details[open] summary{
details[open] summary {
color: rgb(224, 196, 196);
}
@ -37,101 +127,84 @@
display: block;;
} */
img {
#annotation_manager {
display: grid;
gap: 20px;
grid-template-columns: 200px auto;
}
#tags {
grid-column: 1;
}
#annotations {
grid-column: 2;
}
#annotations ul {
grid-template-columns: repeat(auto-fill, 320px);
grid-gap: 20px;
display: grid;
list-style: none;
margin: 0;
padding: 0;
}
#annotations li {}
#annotations img {
/* width: 400px; */
background: white;
width: 300px;
height: 200px;
cursor: pointer;
padding: 20px;
padding: 10px;
}
.svganim_player {
#annotations .svganim_player {
display: inline-block;
position: relative;
width: 300px;
height: 200px;
overflow: hidden;
padding: 20px;
padding: 10px;
background: white;
}
.svganim_player svg {
#annotations .svganim_player svg {
width: 100%;
height: 100%;
}
.svganim_player.play:not(.loading) .controls {
#annotations .svganim_player.play:not(.loading) .controls {
visibility: hidden;
}
.svganim_player:hover .controls {
#annotations .svganim_player:hover .controls {
visibility: visible !important;
}
</style>
<script src="assets/nouislider-15.5.0.js"></script>
<script src="assets/wNumb-1.2.0.min.js"></script>
<script src="annotate.js"></script>
<script src="playlist.js"></script>
<script src="annotations.js"></script>
</head>
<body>
{% for tag in index.tags %}
<details>
<summary>
<h2>{{tag}} ({{len(index.tags[tag])}})</h2>
</summary>
<ul>
{% for annotation in index.tags[tag] %}
<li>
<img src="/annotation/{{ annotation.id }}.svg" loading="lazy" id="img-{{ annotation_hash(annotation.id) }}">
<div class="play" id="annotation-{{ annotation_hash(annotation.id) }}"></div>
<script type='text/javascript'>
(function () {
let imgEl = document.getElementById('img-{{ annotation_hash(annotation.id) }}');
imgEl.addEventListener('click', () => {
imgEl.style.display = 'none';
new Annotator(
document.getElementById("annotation-{{ annotation_hash(annotation.id) }}"),
"tags.json",
"{{ annotation.getJsonUrl() }}",
{ is_player: true, crop_to_fit: true, autoplay: true }
);
})
})();
</script>
</li>
<!-- <li><img src="/annotation/{{ annotation.id }}.svg" data-audio="/annotation/{{ annotation.id }}.mp3"></li> -->
{% end %}
</ul>
</details>
{% end %}
<!-- <ul>
{% for annotation in index.annotations %}
<li>{{ annotation }}</li>
{% end %}
</ul> -->
<div id="annotation_manager">
<ul id="tags"></ul>
<div id="annotations"></div>
</div>
<hr>
<a href="?refresh=1">Reload index</a>
</body>
<script>
let images = document.querySelectorAll('[data-audio]');
for (const image of images) {
const audio = new Audio(image.dataset.audio);
console.log(image, audio);
image.addEventListener('mouseover', (e) => {
audio.play();
});
image.addEventListener('mouseout', (e) => {
audio.pause();
audio.currentTime = 0;
});
}
const am = new AnnotationManager(document.getElementById('annotation_manager'), 'tags.json');
</script>
</html>

View file

@ -1,6 +1,8 @@
import json
import logging
import os
import shutil
from urllib.error import HTTPError
import tornado.ioloop
import tornado.web
import tornado.websocket
@ -11,6 +13,7 @@ import html
import argparse
import coloredlogs
import glob
import filelock
import svganim.strokes
import svganim.uimethods
@ -345,12 +348,21 @@ class TagAnnotationsHandler(tornado.web.RequestHandler):
self.metadir = os.path.join(self.config.storage, "metadata")
def get(self, tag):
if tag not in self.index.tags:
if not self.index.has_tag(tag):
raise tornado.web.HTTPError(404)
self.set_header("Content-Type", "application/json")
annotations = self.index.tags[tag]
self.write(json.dumps(list([a.id for a in annotations])))
# annotations = self.index.tags[tag]
# self.write(json.dumps(list([a.id for a in annotations])))
annotations = self.index.get_nested_annotations_for_tag(tag)
self.write(json.dumps([{
"id": annotation.id,
"tag": annotation.tag,
"id_hash": svganim.uimethods.annotation_hash(input=annotation.id),
"url": annotation.getJsonUrl(),
"comment": annotation.comment,
"drawing": annotation.drawing.get_url()
} for annotation in annotations]))
class AnnotationHandler(tornado.web.RequestHandler):
@ -400,6 +412,41 @@ class AnnotationHandler(tornado.web.RequestHandler):
"tag": annotation.tag,
"audio": f"/annotation/{annotation.id}.mp3",
}))
def post(self, annotation_id):
"""change tag for given annotation"""
if annotation_id not in self.index.annotations:
raise tornado.web.HTTPError(404)
# might be set on file level, but let's try to avoid issues by keeping it simple
lock = filelock.FileLock("metadata_write.lock", timeout=10)
with lock:
newTagId = self.get_argument('tag_id')
if not self.index.has_tag(newTagId):
raise tornado.web.HTTPError(400)
annotation: svganim.strokes.Annotation = self.index.annotations[annotation_id]
logger.info(f"change tag from {annotation.tag} to {newTagId}")
# change metadata and reload index
metadata = annotation.drawing.get_metadata()
change = False
for idx, ann in enumerate(metadata['annotations']):
if ann['t_in'] == annotation.t_in and ann['t_out'] == annotation.t_out and annotation.tag == ann['tag']:
#found!?
metadata['annotations'][idx]['tag'] = newTagId
change = True
break
if change == False:
raise HTTPError(409)
with open(annotation.drawing.metadata_fn, "w") as fp:
logger.info(f"save tag in {annotation.drawing.metadata_fn}")
json.dump(metadata, fp)
self.index.refresh()
class DrawingHandler(tornado.web.RequestHandler):
@ -504,6 +551,44 @@ class AnnotationsHandler(tornado.web.RequestHandler):
with open(meta_file, "w") as fp:
json.dump(self.json_args, fp)
class TagsHandler(tornado.web.RequestHandler):
def initialize(self, config, index: svganim.strokes.AnnotationIndex) -> None:
self.config = config
self.index = index
def get(self):
self.set_header("Content-Type", "application/json")
self.write(self.index.root_tag.toJson(with_counts=True))
# 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)
# update as to load new tag into cache
self.index.refresh()
self.set_status(204)
# print()
class IndexHandler(tornado.web.RequestHandler):
"""Get annotation as svg"""
@ -587,6 +672,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'}),
],

View file

@ -230,30 +230,30 @@ class Annotator extends EventTarget {
this.tagsEl = document.createElement('ul');
this.tagsEl.classList.add('tags');
const addTags = (tags, tagsEl) => {
Object.entries(tags).forEach(([tag, tagData]) => {
tags.forEach((tag) => {
let tagLiEl = document.createElement('li');
let tagEl = document.createElement('div');
tagEl.classList.add('tag');
tagEl.dataset.tag = tag;
tagEl.innerText = tagData.hasOwnProperty('fullname') ? tagData.fullname : tag;
tagEl.dataset.tag = tag.id;
tagEl.innerText = tag.hasOwnProperty('name') ? tag.name : tag.id;
tagEl.addEventListener('click', (e) => {
this.addTag(tag, this.inPointPosition, this.outPointPosition);
this.addTag(tag.id, this.inPointPosition, this.outPointPosition);
});
tagEl.title = tagData.hasOwnProperty('description') ? tagData.description : "";
tagEl.title = tag.hasOwnProperty('description') ? tag.description : "";
let signEl = document.createElement('span');
signEl.classList.add('annotation-' + tag);
signEl.style.backgroundColor = this.getColorForTag(tag);
signEl.classList.add('annotation-' + tag.id);
signEl.style.backgroundColor = this.getColorForTag(tag.id);
tagEl.prepend(signEl);
tagLiEl.appendChild(tagEl);
if (tagData.hasOwnProperty('sub')) {
if (tag.hasOwnProperty('children')) {
const subEl = document.createElement('ul');
subEl.classList.add('subtags');
addTags(tagData.sub, subEl);
addTags(tag.children, subEl);
tagLiEl.appendChild(subEl);
}
@ -304,14 +304,14 @@ class Annotator extends EventTarget {
}
}
getColorForTag(tag) {
const tagData = this.tagMap[tag];
console.log(tag, tagData);
if (tagData && tagData.hasOwnProperty('color')) {
return tagData.color;
getColorForTag(tag_id) {
const tag = this.tagMap[tag_id];
console.log(tag_id, tag);
if (tag && tag.hasOwnProperty('color')) {
return tag.color;
}
if (tagData && tagData.hasOwnProperty('parent')) {
return this.getColorForTag(tagData['parent']);
if (tag && tag.hasOwnProperty('parent')) {
return this.getColorForTag(tag['parent'].id);
}
return 'black';
}
@ -794,15 +794,15 @@ class Annotator extends EventTarget {
const request = new Request(tagFile);
return fetch(request)
.then(response => response.json())
.then(tags => {
this.tags = tags;
.then(rootTag => {
this.tags = rootTag.children;
this.tagMap = {};
const addTagsToMap = (tags, parent) => {
Object.entries(tags).forEach(([tag, tagData]) => {
tagData['parent'] = typeof parent != "undefined" ? parent : null;
this.tagMap[tag] = tagData;
if (tagData.hasOwnProperty("sub")) {
addTagsToMap(tagData.sub, tag);
tags.forEach((tag) => {
tag['parent'] = typeof parent != "undefined" ? parent : null;
this.tagMap[tag.id] = tag;
if (tag.hasOwnProperty("children")) {
addTagsToMap(tag.children, tag);
}
});
};

425
app/www/annotations.js Normal file
View file

@ -0,0 +1,425 @@
class AnnotationManager {
constructor(rootEl, tagsUrl) {
this.rootEl = rootEl;
this.tagsEl = this.rootEl.querySelector('#tags');
this.annotationsEl = this.rootEl.querySelector('#annotations');
this.selectedAnnotations = [];
this.selectedTag = null;
this.tagsUrl = tagsUrl;
this.loadTags();
}
loadTags() {
// tags config
const request = new Request(this.tagsUrl);
return fetch(request)
.then(response => response.json())
.then(rootTag => {
this.rootTag = Tag.from(rootTag);
this.buildTagList()
});
}
buildTagList() {
// build, and rebuild
this.tagsEl.innerHTML = "";
const addTag = (tag, parentEl) => {
const tagLiEl = document.createElement('li');
const tagSubEl = document.createElement('ul');
const tagEl = document.createElement('div');
tagEl.innerText = `${tag.get_name()} (${tag.annotation_count ?? 0})`;
tagEl.addEventListener('click', (ev) => {
this.selectTag(tag);
})
tagEl.addEventListener('dblclick', (ev) => {
this.renameTag(tag);
})
const colorEl = document.createElement('input');
colorEl.type = 'color';
colorEl.value = tag.get_color();
colorEl.addEventListener('change', (ev) => {
this.setTagColor(tag, ev.target.value);
});
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);
tag.menuLiEl = tagLiEl;
tagLiEl.appendChild(tagSubEl);
tag.children.forEach((tag) => addTag(tag, tagSubEl));
const tagAddSubEl = document.createElement('li');
tagAddSubEl.classList.add('add-tag')
tagAddSubEl.innerText = 'add tag';
tagAddSubEl.addEventListener('click', (ev) => {
const name = prompt(`Add a tag under '${tag.get_name()}':`);
if (name === null || name.length < 1) return //cancel
this.addTag(name, tag);
});
tagSubEl.appendChild(tagAddSubEl);
parentEl.appendChild(tagLiEl);
};
addTag(this.rootTag, this.tagsEl);
}
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 = "";
}
selectAnnotation() {
throw new Error("Not implemented");
}
addTag(name, parentTag) {
const tag = new Tag();
tag.id = crypto.randomUUID();
tag.name = name;
tag.parent = parentTag;
parentTag.children.push(tag);
this.buildTagList();
this.saveTags();
}
loadAnnotationsForTag(tag) {
this.annotationsEl.innerHTML = "";
const request = new Request("/tags/" + tag.id);
return fetch(request)
.then(response => response.json())
.then(annotations => {
this.annotations = annotations;
this.buildAnnotationList()
});
}
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('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)
infoEl.innerText = `[${tag.get_name()}] ${annotation.comment}`;
imgEl.src = `/annotation/${annotation.id}.svg`;
imgEl.addEventListener('click', () => {
imgEl.style.display = 'none';
new Annotator(
playerEl,
"tags.json",
annotation.url,
{ is_player: true, crop_to_fit: true, autoplay: true }
);
})
playerEl.classList.add('play');
liEl.appendChild(imgEl);
liEl.appendChild(playerEl);
liEl.appendChild(selectEl);
liEl.appendChild(infoEl);
ulEl.appendChild(liEl);
});
this.annotationsEl.appendChild(ulEl);
}
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()}':`);
if (name === null || name.length < 1) return //cancel
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 => {
if (response.status == 404) {
return [] // not existing tag surely has no annotations
} else {
return response.json()
}
})
.then(annotations => {
if (annotations.length) {
alert(`Cannot remove '${tag.get_name()}', as it is used for ${annotations.length} annotations.`)
} else {
// TODO: remove tag
tag.parent.children.splice(tag.parent.children.indexOf(tag), 1);
tag.parent = null;
this.saveTags();
this.buildTagList();
}
})
}
setTagColor(tag, color) {
tag.color = color;
this.buildTagList();
this.saveTags();
}
async moveSelectedAnnotations(tag) {
// TODO: add button for this
// alert(`This doesn't work yet! (move to tag ${tag.get_name()})`)
await Promise.all(this.selectedAnnotations.map(async (annotation) => {
const formData = new FormData();
formData.append('tag_id', tag.id)
return await fetch('/annotation/'+annotation.id, {
method: 'POST',
// headers: {
// 'Content-Type': 'application/json'
// },
body: formData //JSON.stringify({'tag_id': tag.id})
}).catch((e) => alert('Something went wrong saving the tags'));
}));
this.loadAnnotationsForTag(this.selectedTag)
this.loadTags() //updates the counts
}
async saveTags() {
const json = this.rootTag.export();
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'));
}
}
class Tag {
parent = null;
children = [];
static from(json_obj) {
if (json_obj.hasOwnProperty('children')) {
json_obj.children = json_obj.children.map(Tag.from)
} else {
json_obj.children = [];
}
json_obj.parent = null;
const tag = Object.assign(new Tag(), json_obj);
tag.children.map((child) => child.parent = tag);
return tag;
}
is_root() {
return this.parent === null;
}
descendants(inc_self = false) {
let tags = this.children.flatMap((t) => t.descendants(true))
if (inc_self)
tags.unshift(this)
return tags;
}
find_by_id(tag_id) {
const desc = this.descendants().filter((tag) => tag.id == tag_id);
if (desc.length) return desc[0];
return null;
}
depth() {
if (this.parent === null) {
return 0;
}
return this.parent.depth() + 1;
}
root() {
if (this.parent !== null) {
return this.parent.root();
}
return this;
}
get_name() {
if (this.hasOwnProperty('name') && this.name !== null) {
return this.name;
}
return this.id;
}
get_indented_name() {
const name = this.get_name();
return '&nbsp;'.repeat((this.depth() - 1) * 2) + '- ' + name
}
get_color() {
if (this.hasOwnProperty('color') && this.color !== null) {
return this.color;
}
if (this.parent !== null) {
return this.parent.get_color()
}
return 'black';
}
_json_replacer(key, value) {
if (key === 'children' && !value.length) {
return undefined;
}
if (key === 'parent' || key === 'menuLiEl') {
return undefined;
}
if (key === 'color' && this.parent !== null && this.parent.get_color() === value) {
return undefined;
}
return value;
}
export() {
return JSON.stringify(this, this._json_replacer)
}
}

View file

@ -1,81 +1,172 @@
{
"human-machine": {
"fullname": "Human/machine Entanglements (Appearance/disappearance)",
"color": "orange",
"sub": {
"vision": {
"fullname": "Vision",
"color": "orange"
},
"sound": {},
"behaviour": {},
"other-senses": {
"fullname": "Other senses"
}
"id": "root",
"name": "root",
"color": null,
"description": "",
"children": [
{
"id": "human-machine",
"name": "Human/machine Entanglements (Appearance/disappearance)",
"color": "#ffa348",
"description": "",
"children": [
{
"id": "vision",
"name": "Vision",
"color": null,
"description": ""
},
{
"id": "sound",
"name": "sound",
"color": null,
"description": ""
},
{
"id": "behaviour",
"name": "behaviour",
"color": null,
"description": ""
},
{
"id": "other-senses",
"name": "Other senses",
"color": null,
"description": ""
}
]
},
"tensions": {
"fullname": "Tensions, contestations & problems",
"description" : "Which problems are identified?, when do they become problems?",
"color": "gray"
{
"id": "tensions",
"name": "Tensions, contestations & problems",
"color": "#77767b",
"description": "Which problems are identified?, when do they become problems?"
},
"security": {
"color": "blue",
"fullname": "Security & types of data",
"sub": {
"definitions": {
"description": "e.g. domain knowledge"
},
"input": {
"description": "e.g. fake data"
}
{
"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"
}
]
},
"actants":{
"fullname": "Actants in relation",
"color": "pink",
"sub":{
"algorithm": {},
"technologies": {},
"frt": {},
"cameras": {
"fullname": "CCTV & camera's"
},
"entities":{
"fullname": "Entities: people, institutions etc."
},
"positioning":{
"fullname": "Positioning",
"description": "the positioning of a field/person/oneself in relation to others"
},
"inside-outside":{},
"public-private":{}
{
"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": ""
}
]
},
"consequences":{
"color": "green",
"sub":{
"effects":{},
"future-imaginaries":{},
"speculations": {
"description": "what is & what will/can be done."
},
"innovations": {}
{
"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": ""
}
]
},
"hesitation":{
"fullname": "Hesitations & corrections",
"color": "yellow"
{
"id": "hesitation",
"name": "Hesitations & corrections",
"color": "#f8e45c",
"description": ""
},
"skip":{
"color": "black"
{
"id": "skip",
"name": "skip",
"color": null,
"description": ""
},
"todo":{
"fullname": "to do / interesting",
"color": "red"
{
"id": "todo",
"name": "to do / interesting",
"color": "#ff0000",
"description": ""
}
]
}

49
poetry.lock generated
View file

@ -1,3 +1,18 @@
[[package]]
name = "anytree"
version = "2.8.0"
description = "Powerful and Lightweight Python Tree Data Structure.."
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
six = ">=1.9.0"
[package.extras]
dev = ["check-manifest"]
test = ["coverage"]
[[package]]
name = "coloredlogs"
version = "15.0.1"
@ -12,6 +27,18 @@ humanfriendly = ">=9.1"
[package.extras]
cron = ["capturer (>=2.4)"]
[[package]]
name = "filelock"
version = "3.7.1"
description = "A platform independent file lock."
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"]
testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"]
[[package]]
name = "humanfriendly"
version = "10.0"
@ -39,6 +66,14 @@ category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "svgwrite"
version = "1.4.2"
@ -58,13 +93,21 @@ python-versions = ">= 3.5"
[metadata]
lock-version = "1.1"
python-versions = "^3.9"
content-hash = "b989d535550aaf74b32cd9d2ff48ab7ed8d7d3fd5f0386bc2d6385d65adbf438"
content-hash = "11298c8670e03d4235a76555b1ca7e6cbf0740041f14c14fdf409f633e197bf5"
[metadata.files]
anytree = [
{file = "anytree-2.8.0-py2.py3-none-any.whl", hash = "sha256:14c55ac77492b11532395049a03b773d14c7e30b22aa012e337b1e983de31521"},
{file = "anytree-2.8.0.tar.gz", hash = "sha256:3f0f93f355a91bc3e6245319bf4c1d50e3416cc7a35cc1133c1ff38306bbccab"},
]
coloredlogs = [
{file = "coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934"},
{file = "coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"},
]
filelock = [
{file = "filelock-3.7.1-py3-none-any.whl", hash = "sha256:37def7b658813cda163b56fc564cdc75e86d338246458c4c28ae84cabefa2404"},
{file = "filelock-3.7.1.tar.gz", hash = "sha256:3a0fd85166ad9dbab54c9aec96737b744106dc5f15c0b09a6744a445299fcf04"},
]
humanfriendly = [
{file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"},
{file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"},
@ -77,6 +120,10 @@ pyreadline3 = [
{file = "pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb"},
{file = "pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae"},
]
six = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
svgwrite = [
{file = "svgwrite-1.4.2-py3-none-any.whl", hash = "sha256:ca63d76396d1f6f099a2b2d8cf1419e1c1de8deece9a2b7f4da0632067d71d43"},
{file = "svgwrite-1.4.2.zip", hash = "sha256:d304a929f197d31647c287c10eee0f64378058e1c83a9df83433a5980864e59f"},

View file

@ -10,6 +10,8 @@ tornado = "^6.1"
coloredlogs = "^15.0.1"
pydub = "^0.25.1"
svgwrite = "^1.4.1"
anytree = "^2.8.0"
filelock = "^3.7.1"
[tool.poetry.dev-dependencies]