PK ! ڕ# imagedb/__init__.pyfrom flask import Flask
app = Flask(__name__)
from .views import index, get_image
from .api import create_image, rename_image
from .main import ImageDB
PK ! Փ)_ _ imagedb/api.pyfrom flask import request, jsonify, make_response, abort
from werkzeug.utils import secure_filename
from io import BytesIO
from . import app, db
from .config import config
filename = None
@app.route('/api/images/create', methods=['POST'])
def create_image():
global filename
if 'file' in request.files:
tags = request.form.get('tags')
file = request.files['file']
with BytesIO() as bytes_io:
file.save(bytes_io)
db_image = db.Image.from_bytes_io(bytes_io,
filename=secure_filename(file.filename), tags=tags)
if isinstance(db_image, str):
return abort(make_response(jsonify(message=db_image), 409))
else:
filename = db_image.filename
return jsonify({
'filename': db_image.filename,
'trueFilename': str(db_image.path)
}), 201
response = make_response()
response.status_code = 304
return response
@app.route('/api/images/rename', methods=['POST'])
def rename_image():
global filename
db_image = config['session'].query(db.Image).filter_by(_filename=filename).first()
if filename is not None and db_image is not None:
post_json = request.get_json()
db_image.add_tags(post_json['tags'])
db_image.filename = post_json['filename']
return jsonify({
'filename': db_image.filename,
'trueFilename': str(db_image.path)
}), 201
response = make_response()
response.status_code = 304
return response
PK ! O imagedb/config.pyconfig = {
'recent': []
}
PK ! ;6 ;6
imagedb/db.pyfrom datetime import datetime
from pathlib import Path
import shutil
from nonrepeat import nonrepeat_filename
import PIL.Image
import imagehash
from uuid import uuid4
from slugify import slugify
import logging
import base64
import os
from urllib.parse import quote
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, select
from sqlalchemy.orm import relationship, deferred
from .util import shrink_image, trim_image, HAlign, VAlign
from .config import config
Base = declarative_base()
SIMILARITY_THRESHOLD = 3
class Image(Base):
__tablename__ = 'image'
id = Column(Integer, primary_key=True, autoincrement=True)
_filename = Column(String, nullable=False, unique=True)
info = Column(String, nullable=True, unique=True)
created = Column(DateTime, default=datetime.now)
modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
image_hash = Column(String, nullable=False, unique=True)
tag_image_connects = relationship('TagImageConnect', order_by='TagImageConnect.tag_name', back_populates='image')
def to_json(self):
return {
'id': self.id,
'filename': self.filename,
'hash': self.image_hash,
'info': self.info,
'created': self.created.isoformat(),
'modified': self.modified.isoformat(),
'tags': self.tags
}
@property
def url(self):
return 'http://{}:{}/images?filename={}&time={}'.format(
os.getenv('HOST', 'localhost'),
os.getenv('PORT', '8000'),
quote(str(self.path), safe=''),
quote(datetime.now().isoformat())
)
def to_base64(self):
with self.path.open('rb') as f:
base64_str = base64.b64encode(f.read()).decode()
return ''.format(base64_str)
def to_relative_path(self):
return '
'.format(str(self.path.relative_to('.')))
def to_url(self):
return '
'.format(self.url)
def _repr_html_(self):
if os.getenv('IMAGE_SERVER', '1') == '1':
return self.to_url()
else:
return self.to_base64()
# def __repr__(self):
# return repr(self.to_json())
def add_tags(self, tags='marked'):
"""
:param str|list|tuple tags:
:return:
"""
def _mark(tag):
db_tag = config['session'].query(Tag).filter_by(name=tag).first()
if db_tag is None:
db_tag = Tag()
db_tag.name = tag
config['session'].add(db_tag)
config['session'].commit()
db_tic = config['session'].query(TagImageConnect).filter_by(tag_id=db_tag.id,
image_id=self.id).first()
if db_tic is None:
db_tic = TagImageConnect()
db_tic.tag_id = db_tag.id
db_tic.image_id = self.id
config['session'].add(db_tic)
config['session'].commit()
else:
pass
# raise ValueError('The card is already marked by "{}".'.format(tag))
return db_tag
if isinstance(tags, str):
_mark(tags)
else:
for x in tags:
_mark(x)
def remove_tags(self, tags='marked'):
"""
:param str|list|tuple tags:
:return:
"""
def _unmark(tag):
db_tag = config['session'].query(Tag).filter_by(name=tag).first()
if db_tag is None:
raise ValueError('Cannot unmark "{}"'.format(tag))
# return
db_tic = config['session'].query(TagImageConnect).filter_by(tag_id=db_tag.id,
image_id=self.id).first()
if db_tic is None:
raise ValueError('Cannot unmark "{}"'.format(tag))
# return
else:
config['session'].delete(db_tic)
config['session'].commit()
return db_tag
if isinstance(tags, str):
_unmark(tags)
else:
for x in tags:
_unmark(x)
@property
def filename(self):
return self._filename
@filename.setter
def filename(self, new_filename):
if self.filename:
if self.filename != new_filename:
new_filename = Path(new_filename)
new_filename = new_filename \
.with_name(new_filename.name)\
.with_suffix(self.path.suffix)
new_filename = nonrepeat_filename(str(new_filename),
primary_suffix='-'.join(self.tags),
root=config['folder'])
true_filename = Path(config['folder']).joinpath(new_filename)
true_filename.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(self.path), str(true_filename))
config['recent'].append({
# 'db': [self.versions[0]],
'moved': [(str(self.path), str(true_filename))]
})
self._filename = new_filename
config['session'].commit()
else:
pass
else:
self._filename = new_filename
config['session'].add(self)
config['session'].commit()
@property
def tags(self):
return [tic.tag.name for tic in self.tag_image_connects]
@classmethod
def from_bytes_io(cls, im_bytes_io, filename=None, tags=None):
"""
:param im_bytes_io:
:param str filename:
:param str|list|tuple tags:
:return:
"""
if not filename or filename == 'image.png':
filename = 'blob/' + str(uuid4())[:8] + '.png'
image_path = Path(config['folder'])
image_path.joinpath(filename).parent.mkdir(parents=True, exist_ok=True)
filename = str(image_path.joinpath(filename)
.relative_to(config['folder']))
filename = nonrepeat_filename(filename,
primary_suffix=slugify('-'.join(tags)),
root=str(image_path))
return cls._create(filename, tags=tags, pil_handle=im_bytes_io)
@classmethod
def from_existing(cls, abs_path, rel_path=None, tags=None):
if rel_path is None:
try:
rel_path = abs_path.relative_to(config['folder'])
except ValueError:
rel_path = Path(abs_path.name)
return cls._create(filename=str(rel_path), tags=tags, pil_handle=abs_path)
@classmethod
def _create(cls, filename, tags, pil_handle):
image_path = Path(config['folder'])
image_path.joinpath(filename).parent.mkdir(parents=True, exist_ok=True)
true_filename = image_path.joinpath(filename)
do_save = True
if true_filename.exists():
do_save = False
im = PIL.Image.open(pil_handle)
im = trim_image(im)
im = shrink_image(im)
h = str(imagehash.dhash(im))
try:
pre_existing = next(cls.similar_images_by_hash(h))
pre_existing.modified = datetime.now()
config['session'].commit()
err_msg = 'Similar image exists: {}'.format(pre_existing.path)
# raise ValueError(err_msg)
logging.error(err_msg)
return err_msg
except StopIteration:
if do_save:
im.save(true_filename)
db_image = cls()
db_image._filename = filename
db_image.image_hash = h
config['session'].add(db_image)
config['session'].commit()
if tags:
db_image.add_tags(tags)
return db_image
def delete(self, recent_items=None):
if recent_items is None:
recent_items = dict()
for tic in self.tag_image_connects:
# recent_items.setdefault('db', []).append(tic.versions[0])
config['session'].delete(tic)
config['session'].commit()
# recent_items.setdefault('db', []).append(self.versions[0])
config['session'].delete(self)
config['session'].commit()
if self.exists():
to_delete = self.path.with_name('_' + self.path.name)
shutil.move(str(self.path), str(to_delete))
if 'deleted' in recent_items.keys():
recent_items['deleted'].append(to_delete)
else:
recent_items['deleted'] = [to_delete]
config['recent'].append(recent_items)
return recent_items
def exists(self):
return self.path.exists()
@property
def path(self):
return Path(config['folder']).joinpath(self.filename)
@path.setter
def path(self, file_path):
self._filename = file_path.relative_to(Path(config['folder']))
def v_join(self, db_images, h_align=HAlign.CENTER):
return self._join(db_images, h_align=h_align, v_align=None)
def h_join(self, db_images, v_align=VAlign.MIDDLE):
return self._join(db_images, h_align=None, v_align=v_align)
def _join(self, db_images, h_align=None, v_align=None):
if not any(self.id == db_image.id for db_image in db_images):
db_images.insert(0, self)
pil_images = list(map(PIL.Image.open, (db_image.path for db_image in db_images)))
widths, heights = zip(*(i.size for i in pil_images))
max_height = None
max_width = None
total_width = None
total_height = None
x_offset = 0
y_offset = 0
if v_align:
total_width = sum(widths)
max_height = max(heights)
new_im = PIL.Image.new('RGBA', (total_width, max_height))
else:
max_width = max(widths)
total_height = sum(heights)
new_im = PIL.Image.new('RGBA', (max_width, total_height))
for im in pil_images:
w, h = im.size
if v_align:
y_offset = {
VAlign.TOP.value: 0,
VAlign.MIDDLE.value: (max_height - h) / 2,
VAlign.BOTTOM.value: max_height - h
}.get(getattr(v_align, 'value', v_align), 0)
else:
x_offset = {
HAlign.LEFT.value: 0,
HAlign.CENTER.value: (max_width - w) / 2,
HAlign.RIGHT.value: max_width - w
}.get(getattr(h_align, 'value', h_align), 0)
new_im.paste(im, (int(x_offset), int(y_offset)))
if v_align:
x_offset += w
else:
y_offset += h
assert x_offset == total_width or y_offset == total_height
temp_path = self.path.with_name('_' + self.path.name)
shutil.move(src=str(self.path), dst=str(temp_path))
new_im.save(self.path)
recent_items = {
'deleted': [temp_path]
}
for db_image in db_images:
if self.id != db_image.id:
self.add_tags(db_image.tags)
recent_items = db_image.delete(recent_items)
config['recent'].append(recent_items)
return self
@classmethod
def similar_images_by_hash(cls, h):
for db_image in config['session'].query(cls).all():
if imagehash.hex_to_hash(db_image.image_hash) - imagehash.hex_to_hash(h) < SIMILARITY_THRESHOLD:
yield db_image
@classmethod
def similar_images(cls, im):
h = str(imagehash.dhash(im))
yield from cls.similar_images_by_hash(h)
def replace_with(self, newer_db_image):
recent_items = {
# 'db': [self.versions[0], newer_db_image.versions[0]],
'deleted': [self.path.with_name('_' + self.path.name)],
'moved': [(str(newer_db_image.path), str(self.path))]
}
shutil.move(str(self.path), str(self.path.with_name('_' + self.path.name)))
shutil.move(str(newer_db_image.path), str(self.path))
self._filename = newer_db_image.filename
config['session'].delete(newer_db_image)
config['session'].commit()
config['recent'].append(recent_items)
return self
class Tag(Base):
__tablename__ = 'tag'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False)
tag_image_connects = relationship('TagImageConnect', order_by='TagImageConnect.id', back_populates='tag')
def to_json(self):
return {
'id': self.id,
'name': self.name,
'images': [tic.image.to_json() for tic in self.tag_image_connects]
}
def __repr__(self):
return repr(self.to_json())
class TagImageConnect(Base):
__tablename__ = 'tag_image_connect'
id = Column(Integer, primary_key=True, autoincrement=True)
image_id = Column(Integer, ForeignKey('image.id'), nullable=False)
tag_id = Column(Integer, ForeignKey('tag.id'), nullable=False)
image = relationship('Image', back_populates='tag_image_connects')
tag = relationship('Tag', back_populates='tag_image_connects')
tag_name = deferred(select([Tag.name]).where(Tag.id == tag_id))
def to_json(self):
return {
'id': self.id,
'image': self.image.to_json(),
'tag': self.tag.name
}
def __repr__(self):
return repr(self.to_json())
PK ! !* * imagedb/main.pyfrom threading import Thread
import re
import atexit
from send2trash import send2trash
import shutil
from pathlib import Path
from datetime import datetime, timedelta
from IPython.display import display
import sys
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from . import db, app
from .util import open_browser_tab
from .config import config
class ImageDB:
def __init__(self, db_path, host='localhost', port='8000', debug=False, runserver=False):
os.environ.update({
'HOST': host,
'PORT': port,
'DEBUG': '1' if debug else '0',
'IMAGE_SERVER': '1'
})
self.folder = os.path.splitext(db_path)[0]
self.engine = create_engine('sqlite:///' + os.path.abspath(db_path),
connect_args={'check_same_thread': False})
self.session = sessionmaker(bind=self.engine)()
if not os.path.exists(db_path):
db.Base.metadata.create_all(self.engine)
self.server_thread = None
if runserver:
self.runserver()
atexit.register(self._cleanup)
config.update({
'session': self.session,
'folder': self.folder
})
@staticmethod
def _cleanup():
for stack in config['recent']:
for path in stack['deleted']:
send2trash(str(path))
def runserver(self):
def _runserver():
app.run(
host=os.getenv('HOST', 'localhost'),
port=os.getenv('PORT', '8000'),
debug=True if os.getenv('DEBUG', '0') == '1' else False
)
def _runserver_in_thread():
open_browser_tab('http://{}:{}'.format(
os.getenv('HOST', 'localhost'),
os.getenv('PORT', '8000')
))
self.server_thread = Thread(target=_runserver)
self.server_thread.daemon = True
self.server_thread.start()
if 'ipykernel' in ' '.join(sys.argv):
_runserver_in_thread()
elif os.getenv('THREADED_IMAGE_SERVER', '0') == '1':
_runserver_in_thread()
else:
_runserver()
def search(self, filename=None, tags=None, content=None,
type_='partial', since=None, until=None,
filename_regex=None):
def _compare(text, text_compare):
if type_ == 'partial':
return text_compare in text
elif type_ in {'regex', 'regexp', 're'}:
return re.search(text_compare, text, flags=re.IGNORECASE)
else:
return text_compare == text
def _filter_filename(text_compare, q):
for db_image in q:
if any(_compare(tag, text_compare) for tag in db_image.filename):
yield db_image
def _filter_tag(text_compare, q):
for db_image in q:
if any(_compare(tag, text_compare) for tag in db_image.tags):
yield db_image
def _filter_slide(text_compare, q):
for db_card in q:
if any(_compare(db_image.info, text_compare) for db_image in q if db_image.info):
yield db_card
query = self.session.query(db.Image)
if since:
if isinstance(since, timedelta):
since = datetime.now() - since
query = query.filter(db.Image.modified > since)
if until:
query = query.filter(db.Image.modified < until)
query = iter(query.order_by(db.Image.modified.desc()))
if tags:
if isinstance(tags, str):
query = _filter_tag(tags, query)
else:
for x in tags:
query = _filter_tag(x, query)
if content:
query = _filter_slide(content, query)
if filename_regex:
filename = filename_regex
type_ = 're'
if filename:
query = _filter_filename(filename, query)
return query
def search_filename(self, filename_regex):
for path in Path(self.folder).glob('**/*.*'):
if path.suffix.lower() in {'.png', '.jpg', '.jp2', '.jpeg', '.gif'}:
if re.search(filename_regex, str(path), re.IGNORECASE):
db_image = db.Image()
db_image.path = path
yield db_image
def optimize(self):
paths = set()
for db_image in self.search():
if not db_image.exists():
print(db_image)
db_image.delete()
else:
paths.add(db_image.path)
for path in Path(self.folder).glob('**/*.*'):
if path.suffix.lower() in {'.png', '.jpg', '.jp2', '.jpeg', '.gif'}:
if path not in paths:
print(path)
send2trash(str(path))
def undo(self):
if len(config['recent']) > 0:
stack = config['recent'].pop()
for path in stack.get('deleted', []):
path = Path(path)
if path.name[0] == '_':
if path.with_name(path.name[1:]).exists():
shutil.move(str(path.with_name(path.name[1:])), str(path.with_name('_' + path.name)))
atexit.register(send2trash,
str(path.with_name('_' + path.name)))
shutil.move(str(path), str(path.with_name(path.name[1:])))
for src, dst in stack.get('moved', []):
shutil.move(src=dst, dst=src)
self.session.commit()
def last(self, count=1):
for i, db_image in enumerate(self.search()):
if i >= count:
break
display(db_image)
def import_images(self, file_path=None, tags=None):
if file_path is None:
file_path = self.folder
for p in Path(file_path).glob('**/*.*'):
if p.suffix.lower() in {'.png', '.jpg', '.jp2', '.jpeg', '.gif'}:
db.Image.from_existing(p, tags=tags)
PK ! " imagedb/static/index.cssinput {
font-size: 24px;
margin: 0 auto;
margin-top: 20px;
}
#tags-area, #input-bar {
width: 500px;
}
#tags-bar {
width: 100%;
}
img {
margin-top: 20px;
}
PK ! ?| | imagedb/static/index.jsconst tagsBar = document.getElementById('tags-bar');
const inputBar = document.getElementById('input-bar');
const imageCanvas = document.getElementById('image-canvas');
let tags = JSON.parse(localStorage.getItem('tags') || '[]');
let imagePath = '';
tagsBar.value = joinTags();
inputBar.value = imagePath;
inputBar.onpaste = ()=>{
const items = (event.clipboardData || event.originalEvent.clipboardData).items;
// console.log(items); // will give you the mime types
for (index in items) {
const item = items[index];
if (item.kind === 'file') {
const file = item.getAsFile();
let reader = new FileReader();
reader.onload = function(event) {
const extension = file.type.match(/\/([a-z0-9]+)/i)[1].toLowerCase();
let formData = new FormData();
formData.append('file', file, file.name);
formData.append('extension', extension);
formData.append('mimetype', file.type);
formData.append('submission-type', 'paste');
// formData.append('imagePath', imagePath);
formData.append('tags', tags);
localStorage.setItem('tags', JSON.stringify(tags));
fetch('/api/images/create', {
method: 'POST',
body: formData
}).then(response=>response.json())
.then(responseJson=>{
if(responseJson.filename){
inputBar.value = responseJson.filename;
imageCanvas.src = '/images?filename=' + encodeURIComponent(responseJson.trueFilename);
} else {
alert(responseJson.message);
}
})
.catch(error => {
console.error(error);
});
};
reader.readAsBinaryString(file);
}
}
}
inputBar.addEventListener("keyup", function(event) {
imagePath = inputBar.value;
if (event.keyCode === 13) {
localStorage.setItem('tags', JSON.stringify(tags));
fetch('/api/images/rename', {
method: 'POST',
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({
'filename': imagePath,
'tags': tags
})
}).then(response=>response.json())
.then(responseJson=>{
inputBar.value = responseJson.filename;
imageCanvas.src = '/images?filename=' + encodeURIComponent(responseJson.trueFilename);
alert('Renaming successful!');
});
}
});
tagsBar.addEventListener("keyup", function(event) {
function purge(tag){
tag = tag.trim();
if(tag){
tags.push(tag);
}
}
tags = [];
let purgable = true;
let currentTag = ''
tagsBar.value.split('').forEach((character, index)=>{
if(character === ',' && purgable){
if(purgable){
purge(currentTag);
currentTag = '';
} else {
currentTag += character;
}
} else if (character === '\"'){
purgable = !purgable;
} else {
currentTag += character;
}
});
purge(currentTag);
});
function joinTags(){
let result = []
tags.forEach((tag, index)=>{
if(tag.indexOf(',') !== -1){
result.push('\"' + tag + '\"');
} else {
result.push(tag);
}
});
return result.join(', ')
}
PK ! imagedb/templates/index.html