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!Oimagedb/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

PK!A}>>>imagedb/util.pyfrom threading import Thread from time import sleep import webbrowser from PIL import Image, ImageChops import enum from pathlib import Path import imagehash from send2trash import send2trash def open_browser_tab(url): def _open_tab(): sleep(1) webbrowser.open_new_tab(url) thread = Thread(target=_open_tab) thread.daemon = True thread.start() def trim_image(im): bg = Image.new(im.mode, im.size, im.getpixel((0,0))) diff = ImageChops.difference(im, bg) diff = ImageChops.add(diff, diff, 2.0, -100) bbox = diff.getbbox() if bbox: im = im.crop(bbox) return im def shrink_image(im, max_width=800): width, height = im.size if width > max_width: im.thumbnail((max_width, height * max_width / width)) return im def remove_duplicate(file_path): hashes = set() for p in Path(file_path).glob('**/*.*'): if p.suffix.lower() in {'.png', '.jpg', '.jp2', '.jpeg', '.gif'}: h = imagehash.dhash(trim_image(shrink_image(Image.open(p)))) if h in hashes: print('Deleting {}'.format(p)) send2trash(p) else: hashes.add(h) class HAlign(enum.Enum): LEFT = -1 CENTER = 0 RIGHT = 1 class VAlign(enum.Enum): TOP = 1 MIDDLE = 0 BOTTOM = -1 PK!8O<22imagedb/views.pyfrom flask import render_template, send_file, request import os from urllib.parse import unquote from . import app @app.route('/') def index(): return render_template('index.html') @app.route('/images') def get_image(): return send_file(os.path.abspath(unquote(request.args.get('filename')))) PK!H WXimagedb-0.1.6.3.dist-info/WHEEL A н#Z."jm)Afb~ڠO68oF04UhoAf f4=4h0k::wXPK!H8sv "imagedb-0.1.6.3.dist-info/METADATAUn8}W b6ec fMݾdI^uq)_,gfΙޣbZ'aVTDYW3>+U%lÝ;:e Ji-l6@@*g¡F;mvnMV{LsVW85tCqquS]E"Xatn?;rru mc(ҢWr ĽS,cxpGNBvU NbpdunEUI;:!7*<-N-^NXl:}ﵴR0\d}gLjhs>Gm*GP^'ETd=Ǒ]&edMԕu.wMGnqC-u Ri#oR+A^iQxG4+`-?;y"Uƶ-MdE.KFY5dB@}9 o [p6 lpS9ˠh %OgIc7!}wUJ곖DVF[A?:|m1sja&R,45lBbLbN C%H\: ߠ{RXi3өVl6+^%>\s ~P+zrAj`wܞfz-9`SZip $0nZL:Sfٿv8Cءi1"w牰qNaZRmiVY؞T@䑈"pZo_cl-M/_ M+v֔'>4"27h|!TчxPPK!He imagedb-0.1.6.3.dist-info/RECORD}ǒP}? d, JP6%gԧݶ:(OSn;2^#f:ֳEsXc6FdB1i~SQqe4u-] #Uzkz].)|ԙA C/&n o^F;v=Aπ'nѻ;!7ܰg~i]3JGs]lZmCpSGy"JlFTώ [ AH[9S46IzE {\uʩT\`T<kd,oxq=ΰHCbE!s~ChD7uWES:~ש~PɕS-k r~3@\ZDh7imagedb/main.pyPK!"`Vimagedb/static/index.cssPK!?| | @Wimagedb/static/index.jsPK!cimagedb/templates/index.htmlPK!A}>>>Gfimagedb/util.pyPK!8O<22kimagedb/views.pyPK!H WXmimagedb-0.1.6.3.dist-info/WHEELPK!H8sv "mimagedb-0.1.6.3.dist-info/METADATAPK!He Yqimagedb-0.1.6.3.dist-info/RECORDPK os