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 from __future__ import annotations
import asyncio import asyncio
import copy import copy
from ctypes.wintypes import tagMSG
import json import json
from os import X_OK, PathLike from os import X_OK, PathLike
import os import os
@ -12,6 +13,9 @@ import svgwrite
import tempfile import tempfile
import io import io
import logging 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') logger = logging.getLogger('svganim.strokes')
@ -19,11 +23,12 @@ Milliseconds = float
Seconds = float Seconds = float
class Annotation: 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.tag = tag
self.t_in = t_in self.t_in = t_in
self.t_out = t_out self.t_out = t_out
self.drawing = drawing self.drawing = drawing
self.comment = comment
@property @property
def id(self) -> str: def id(self) -> str:
@ -232,7 +237,7 @@ class AnimationSlice:
strokes = self.getStrokeSlices(frame_in, frame_out, t_in) strokes = self.getStrokeSlices(frame_in, frame_out, t_in)
# TODO shift t of points with t_in # TODO shift t of points with t_in
viewboxes = self.getViewboxesSlice(t_in, t_out) viewboxes = self.getViewboxesSlice(t_in, t_out)
audio = self.audio.getSlice(t_in, t_out) if self.audio else None 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) 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.drawing_dir = drawing_dir
self.metadata_dir = metadata_dir self.metadata_dir = metadata_dir
self.root_tag = getRootTag()
# disable disk cache because of glitches shelve.open(filename, writeback=True) # disable disk cache because of glitches shelve.open(filename, writeback=True)
self.shelve = {} self.shelve = {}
@ -515,7 +522,9 @@ class AnnotationIndex:
Drawing(fn, self.metadata_dir, self.drawing_dir) for fn in self.get_drawing_filenames() 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'] = {} self.shelve['_annotations'] = {}
drawing: Drawing drawing: Drawing
@ -525,14 +534,19 @@ class AnnotationIndex:
continue continue
for ann in meta['annotations']: for ann in meta['annotations']:
annotation = Annotation( 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 self.shelve['_annotations'][annotation.id] = annotation
if annotation.tag not in self.shelve['_tags']: if annotation.tag not in self.shelve['_tags']:
self.shelve['_tags'][annotation.tag] = [annotation] self.shelve['_tags'][annotation.tag] = [annotation]
logger.error(f"Use of non-existing tag {annotation.tag}")
else: else:
self.shelve['_tags'][annotation.tag].append( self.shelve['_tags'][annotation.tag].append(
annotation annotation
) )
tag = self.root_tag.find_by_id(annotation.tag)
if tag is not None:
tag.annotation_count += 1
@property @property
def drawings(self) -> dict[str, Drawing]: def drawings(self) -> dict[str, Drawing]:
@ -545,11 +559,14 @@ class AnnotationIndex:
@property @property
def annotations(self) -> dict[str, Annotation]: def annotations(self) -> dict[str, Annotation]:
return self.shelve["_annotations"] return self.shelve["_annotations"]
def has_tag(self, tag):
return tag in self.tags
def get_annotations(self, tag) -> list[Annotation]: def get_annotations_for_tag(self, tag_id) -> list[Annotation]:
if tag not in self.tags: if tag_id not in self.tags:
return [] return []
return self.tags[tag] return self.tags[tag_id]
def get_drawing_names(self) -> list[str]: def get_drawing_names(self) -> list[str]:
return [ return [
@ -563,6 +580,14 @@ class AnnotationIndex:
os.path.join(self.drawing_dir, f"{name}.json_appendable") os.path.join(self.drawing_dir, f"{name}.json_appendable")
for name in self.get_drawing_names() 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): def __del__(self):
self.shelve.close() self.shelve.close()
@ -693,3 +718,56 @@ def strokes2D(strokes):
d += f"{rel_stroke[0]},{rel_stroke[1]} " d += f"{rel_stroke[0]},{rel_stroke[1]} "
last_stroke = stroke last_stroke = stroke
return d 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 from hashlib import md5
def annotation_hash(handler, input): def annotation_hash(handler=None, input =""):
return md5(input.encode()).hexdigest() return md5(input.encode()).hexdigest()
# def nmbr(handler, lst) -> int: # def nmbr(handler, lst) -> int:

View file

@ -10,22 +10,112 @@
color: white 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; margin: 0;
padding: 0; padding: 0;
} }
li { #tags li>div,
display: inline-block; #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; display: inline-block;
cursor: pointer; cursor: pointer;
} }
details[open] summary{ details[open] summary {
color: rgb(224, 196, 196); color: rgb(224, 196, 196);
} }
@ -37,101 +127,84 @@
display: block;; 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; */ /* width: 400px; */
background: white; background: white;
width: 300px; width: 300px;
height: 200px; height: 200px;
cursor: pointer; cursor: pointer;
padding: 20px; padding: 10px;
} }
.svganim_player { #annotations .svganim_player {
display: inline-block; display: inline-block;
position: relative; position: relative;
width: 300px; width: 300px;
height: 200px; height: 200px;
overflow: hidden; overflow: hidden;
padding: 20px; padding: 10px;
background: white; background: white;
} }
.svganim_player svg { #annotations .svganim_player svg {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.svganim_player.play:not(.loading) .controls { #annotations .svganim_player.play:not(.loading) .controls {
visibility: hidden; visibility: hidden;
} }
.svganim_player:hover .controls { #annotations .svganim_player:hover .controls {
visibility: visible !important; visibility: visible !important;
} }
</style> </style>
<script src="assets/nouislider-15.5.0.js"></script> <script src="assets/nouislider-15.5.0.js"></script>
<script src="assets/wNumb-1.2.0.min.js"></script> <script src="assets/wNumb-1.2.0.min.js"></script>
<script src="annotate.js"></script> <script src="annotate.js"></script>
<script src="playlist.js"></script> <script src="annotations.js"></script>
</head> </head>
<body> <body>
{% for tag in index.tags %} <div id="annotation_manager">
<details> <ul id="tags"></ul>
<summary> <div id="annotations"></div>
<h2>{{tag}} ({{len(index.tags[tag])}})</h2> </div>
</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> -->
<hr> <hr>
<a href="?refresh=1">Reload index</a> <a href="?refresh=1">Reload index</a>
</body> </body>
<script> <script>
let images = document.querySelectorAll('[data-audio]'); const am = new AnnotationManager(document.getElementById('annotation_manager'), 'tags.json');
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;
});
}
</script> </script>
</html> </html>

View file

@ -1,6 +1,8 @@
import json import json
import logging import logging
import os import os
import shutil
from urllib.error import HTTPError
import tornado.ioloop import tornado.ioloop
import tornado.web import tornado.web
import tornado.websocket import tornado.websocket
@ -11,6 +13,7 @@ import html
import argparse import argparse
import coloredlogs import coloredlogs
import glob import glob
import filelock
import svganim.strokes import svganim.strokes
import svganim.uimethods import svganim.uimethods
@ -345,12 +348,21 @@ class TagAnnotationsHandler(tornado.web.RequestHandler):
self.metadir = os.path.join(self.config.storage, "metadata") self.metadir = os.path.join(self.config.storage, "metadata")
def get(self, tag): def get(self, tag):
if tag not in self.index.tags: if not self.index.has_tag(tag):
raise tornado.web.HTTPError(404) raise tornado.web.HTTPError(404)
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
annotations = self.index.tags[tag] # annotations = self.index.tags[tag]
self.write(json.dumps(list([a.id for a in annotations]))) # 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): class AnnotationHandler(tornado.web.RequestHandler):
@ -400,6 +412,41 @@ class AnnotationHandler(tornado.web.RequestHandler):
"tag": annotation.tag, "tag": annotation.tag,
"audio": f"/annotation/{annotation.id}.mp3", "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): class DrawingHandler(tornado.web.RequestHandler):
@ -504,6 +551,44 @@ class AnnotationsHandler(tornado.web.RequestHandler):
with open(meta_file, "w") as fp: with open(meta_file, "w") as fp:
json.dump(self.json_args, 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): class IndexHandler(tornado.web.RequestHandler):
"""Get annotation as svg""" """Get annotation as svg"""
@ -587,6 +672,9 @@ class Server:
(r"/index", IndexHandler, (r"/index", IndexHandler,
{"config": self.config, "index": self.index}), {"config": self.config, "index": self.index}),
(r"/tags.json", TagsHandler,
{"config": self.config, "index": self.index}),
(r"/(.*)", StaticFileWithHeaderHandler, (r"/(.*)", StaticFileWithHeaderHandler,
{"path": self.web_root, 'default_filename': 'index.html'}), {"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 = document.createElement('ul');
this.tagsEl.classList.add('tags'); this.tagsEl.classList.add('tags');
const addTags = (tags, tagsEl) => { const addTags = (tags, tagsEl) => {
Object.entries(tags).forEach(([tag, tagData]) => { tags.forEach((tag) => {
let tagLiEl = document.createElement('li'); let tagLiEl = document.createElement('li');
let tagEl = document.createElement('div'); let tagEl = document.createElement('div');
tagEl.classList.add('tag'); tagEl.classList.add('tag');
tagEl.dataset.tag = tag; tagEl.dataset.tag = tag.id;
tagEl.innerText = tagData.hasOwnProperty('fullname') ? tagData.fullname : tag; tagEl.innerText = tag.hasOwnProperty('name') ? tag.name : tag.id;
tagEl.addEventListener('click', (e) => { 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'); let signEl = document.createElement('span');
signEl.classList.add('annotation-' + tag); signEl.classList.add('annotation-' + tag.id);
signEl.style.backgroundColor = this.getColorForTag(tag); signEl.style.backgroundColor = this.getColorForTag(tag.id);
tagEl.prepend(signEl); tagEl.prepend(signEl);
tagLiEl.appendChild(tagEl); tagLiEl.appendChild(tagEl);
if (tagData.hasOwnProperty('sub')) { if (tag.hasOwnProperty('children')) {
const subEl = document.createElement('ul'); const subEl = document.createElement('ul');
subEl.classList.add('subtags'); subEl.classList.add('subtags');
addTags(tagData.sub, subEl); addTags(tag.children, subEl);
tagLiEl.appendChild(subEl); tagLiEl.appendChild(subEl);
} }
@ -304,14 +304,14 @@ class Annotator extends EventTarget {
} }
} }
getColorForTag(tag) { getColorForTag(tag_id) {
const tagData = this.tagMap[tag]; const tag = this.tagMap[tag_id];
console.log(tag, tagData); console.log(tag_id, tag);
if (tagData && tagData.hasOwnProperty('color')) { if (tag && tag.hasOwnProperty('color')) {
return tagData.color; return tag.color;
} }
if (tagData && tagData.hasOwnProperty('parent')) { if (tag && tag.hasOwnProperty('parent')) {
return this.getColorForTag(tagData['parent']); return this.getColorForTag(tag['parent'].id);
} }
return 'black'; return 'black';
} }
@ -794,15 +794,15 @@ class Annotator extends EventTarget {
const request = new Request(tagFile); const request = new Request(tagFile);
return fetch(request) return fetch(request)
.then(response => response.json()) .then(response => response.json())
.then(tags => { .then(rootTag => {
this.tags = tags; this.tags = rootTag.children;
this.tagMap = {}; this.tagMap = {};
const addTagsToMap = (tags, parent) => { const addTagsToMap = (tags, parent) => {
Object.entries(tags).forEach(([tag, tagData]) => { tags.forEach((tag) => {
tagData['parent'] = typeof parent != "undefined" ? parent : null; tag['parent'] = typeof parent != "undefined" ? parent : null;
this.tagMap[tag] = tagData; this.tagMap[tag.id] = tag;
if (tagData.hasOwnProperty("sub")) { if (tag.hasOwnProperty("children")) {
addTagsToMap(tagData.sub, tag); 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": { "id": "root",
"fullname": "Human/machine Entanglements (Appearance/disappearance)", "name": "root",
"color": "orange", "color": null,
"sub": { "description": "",
"vision": { "children": [
"fullname": "Vision", {
"color": "orange" "id": "human-machine",
}, "name": "Human/machine Entanglements (Appearance/disappearance)",
"sound": {}, "color": "#ffa348",
"behaviour": {}, "description": "",
"other-senses": { "children": [
"fullname": "Other senses" {
} "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", "id": "tensions",
"description" : "Which problems are identified?, when do they become problems?", "name": "Tensions, contestations & problems",
"color": "gray" "color": "#77767b",
"description": "Which problems are identified?, when do they become problems?"
}, },
"security": { {
"color": "blue", "id": "security",
"fullname": "Security & types of data", "name": "Security & types of data",
"sub": { "color": "#3584e4",
"definitions": { "description": "",
"description": "e.g. domain knowledge" "children": [
}, {
"input": { "id": "definitions",
"description": "e.g. fake data" "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", "id": "actants",
"color": "pink", "name": "Actants in relation",
"sub":{ "color": "#fa08ff",
"algorithm": {}, "description": "",
"technologies": {}, "children": [
"frt": {}, {
"cameras": { "id": "algorithm",
"fullname": "CCTV & camera's" "name": "algorithm",
}, "color": null,
"entities":{ "description": ""
"fullname": "Entities: people, institutions etc." },
}, {
"positioning":{ "id": "technologies",
"fullname": "Positioning", "name": "technologies",
"description": "the positioning of a field/person/oneself in relation to others" "color": null,
}, "description": ""
"inside-outside":{}, },
"public-private":{} {
"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", "id": "consequences",
"sub":{ "name": "consequences",
"effects":{}, "color": "#0add32",
"future-imaginaries":{}, "description": "",
"speculations": { "children": [
"description": "what is & what will/can be done." {
}, "id": "effects",
"innovations": {} "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":{ "id": "hesitation",
"fullname": "Hesitations & corrections", "name": "Hesitations & corrections",
"color": "yellow" "color": "#f8e45c",
"description": ""
}, },
{
"skip":{ "id": "skip",
"color": "black" "name": "skip",
"color": null,
"description": ""
}, },
{
"todo":{ "id": "todo",
"fullname": "to do / interesting", "name": "to do / interesting",
"color": "red" "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]] [[package]]
name = "coloredlogs" name = "coloredlogs"
version = "15.0.1" version = "15.0.1"
@ -12,6 +27,18 @@ humanfriendly = ">=9.1"
[package.extras] [package.extras]
cron = ["capturer (>=2.4)"] 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]] [[package]]
name = "humanfriendly" name = "humanfriendly"
version = "10.0" version = "10.0"
@ -39,6 +66,14 @@ category = "dev"
optional = false optional = false
python-versions = "*" 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]] [[package]]
name = "svgwrite" name = "svgwrite"
version = "1.4.2" version = "1.4.2"
@ -58,13 +93,21 @@ python-versions = ">= 3.5"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.9" python-versions = "^3.9"
content-hash = "b989d535550aaf74b32cd9d2ff48ab7ed8d7d3fd5f0386bc2d6385d65adbf438" content-hash = "11298c8670e03d4235a76555b1ca7e6cbf0740041f14c14fdf409f633e197bf5"
[metadata.files] [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 = [ coloredlogs = [
{file = "coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934"}, {file = "coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934"},
{file = "coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"}, {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 = [ humanfriendly = [
{file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"}, {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"},
{file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"}, {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-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb"},
{file = "pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae"}, {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 = [ svgwrite = [
{file = "svgwrite-1.4.2-py3-none-any.whl", hash = "sha256:ca63d76396d1f6f099a2b2d8cf1419e1c1de8deece9a2b7f4da0632067d71d43"}, {file = "svgwrite-1.4.2-py3-none-any.whl", hash = "sha256:ca63d76396d1f6f099a2b2d8cf1419e1c1de8deece9a2b7f4da0632067d71d43"},
{file = "svgwrite-1.4.2.zip", hash = "sha256:d304a929f197d31647c287c10eee0f64378058e1c83a9df83433a5980864e59f"}, {file = "svgwrite-1.4.2.zip", hash = "sha256:d304a929f197d31647c287c10eee0f64378058e1c83a9df83433a5980864e59f"},

View file

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