Initial commit
This commit is contained in:
commit
054078908a
12
LICENSE
Normal file
12
LICENSE
Normal file
@ -0,0 +1,12 @@
|
||||
Copyright (c) 2023, Přemysl Eric Janouch <p@janouch.name>
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
|
||||
SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
|
||||
OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
||||
CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
14
Makefile
Normal file
14
Makefile
Normal file
@ -0,0 +1,14 @@
|
||||
.POSIX:
|
||||
.SUFFIXES:
|
||||
|
||||
outputs = gallery initialize.go public/mithril.js
|
||||
all: $(outputs)
|
||||
|
||||
gallery: main.go initialize.go
|
||||
go build -tags "" -gcflags="all=-N -l" -o $@
|
||||
initialize.go: initialize.sql gen-initialize.sh
|
||||
./gen-initialize.sh initialize.sql > $@
|
||||
public/mithril.js:
|
||||
curl -Lo $@ https://unpkg.com/mithril/mithril.js
|
||||
clean:
|
||||
rm -f $(outputs)
|
14
README
Normal file
14
README
Normal file
@ -0,0 +1,14 @@
|
||||
This is gallery software designed to maintain a shadow structure
|
||||
of your filesystem, in which you can attach metadata to your media,
|
||||
and query your collections in various ways.
|
||||
|
||||
All media is content-addressed by its SHA-1 hash value, and at your option
|
||||
also perceptually hashed. Duplicate search is an essential feature.
|
||||
|
||||
Prerequisites: Go, ImageMagick, xdg-utils
|
||||
|
||||
The gallery is designed for simplicity, and easy interoperability.
|
||||
sqlite3, curl, jq, and the filesystem will take you a long way.
|
||||
|
||||
The intended mode of use is running daily automated sync/thumbnail/dhash/tag
|
||||
batches in a cron job, or from a system timer. See test.sh for usage hints.
|
6
gen-initialize.sh
Executable file
6
gen-initialize.sh
Executable file
@ -0,0 +1,6 @@
|
||||
#!/bin/sh -e
|
||||
gofmt <<EOF
|
||||
package ${GOPACKAGE:-main}
|
||||
|
||||
const initializeSQL = \`$(sed 's/`/` + "`" + `/g' "$@")\`
|
||||
EOF
|
8
go.mod
Normal file
8
go.mod
Normal file
@ -0,0 +1,8 @@
|
||||
module janouch.name/gallery
|
||||
|
||||
go 1.21.4
|
||||
|
||||
require (
|
||||
github.com/mattn/go-sqlite3 v1.14.19
|
||||
golang.org/x/image v0.14.0
|
||||
)
|
4
go.sum
Normal file
4
go.sum
Normal file
@ -0,0 +1,4 @@
|
||||
github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
|
||||
github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
|
||||
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
105
initialize.sql
Normal file
105
initialize.sql
Normal file
@ -0,0 +1,105 @@
|
||||
CREATE TABLE IF NOT EXISTS image(
|
||||
sha1 TEXT NOT NULL, -- SHA-1 hash of file in lowercase hexadecimal
|
||||
width INTEGER NOT NULL, -- cached media width
|
||||
height INTEGER NOT NULL, -- cached media height
|
||||
thumbw INTEGER, -- cached thumbnail width, if known
|
||||
thumbh INTEGER, -- cached thumbnail height, if known
|
||||
dhash INTEGER, -- uint64 perceptual hash as a signed integer
|
||||
CHECK (unhex(sha1) IS NOT NULL AND lower(sha1) = sha1),
|
||||
PRIMARY KEY (sha1)
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS image__dhash ON image(dhash);
|
||||
|
||||
--
|
||||
|
||||
CREATE TABLE IF NOT EXISTS node(
|
||||
id INTEGER NOT NULL, -- unique ID
|
||||
parent INTEGER REFERENCES node(id), -- root if NULL
|
||||
name TEXT NOT NULL, -- path component
|
||||
mtime INTEGER, -- files: Unix time in seconds
|
||||
sha1 TEXT REFERENCES image(sha1), -- files: content hash
|
||||
PRIMARY KEY (id)
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS node__sha1 ON node(sha1);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS node__parent_name
|
||||
ON node(IFNULL(parent, 0), name);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS node__sha1__check
|
||||
BEFORE UPDATE OF sha1 ON node
|
||||
WHEN OLD.sha1 IS NULL AND NEW.sha1 IS NOT NULL
|
||||
AND EXISTS(SELECT id FROM node WHERE parent = OLD.id)
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'trying to turn a non-empty directory into a file');
|
||||
END;
|
||||
|
||||
/*
|
||||
Automatic garbage collection, not sure if it actually makes any sense.
|
||||
This needs PRAGMA recursive_triggers = 1; to work properly.
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS node__parent__gc
|
||||
AFTER DELETE ON node FOR EACH ROW
|
||||
BEGIN
|
||||
DELETE FROM node WHERE id = OLD.parent
|
||||
AND id NOT IN (SELECT DISTINCT parent FROM node);
|
||||
END;
|
||||
*/
|
||||
|
||||
--
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orphan(
|
||||
sha1 TEXT NOT NULL REFERENCES image(sha1),
|
||||
path TEXT NOT NULL, -- last occurence within the database hierarchy
|
||||
PRIMARY KEY (sha1)
|
||||
) STRICT;
|
||||
|
||||
-- Renaming/moving a file can result either in a (ref, unref) or a (unref, ref)
|
||||
-- sequence during sync, and I want to get at the same result.
|
||||
CREATE TRIGGER IF NOT EXISTS node__sha1__deorphan_insert
|
||||
AFTER INSERT ON node
|
||||
WHEN NEW.sha1 IS NOT NULL
|
||||
BEGIN
|
||||
DELETE FROM orphan WHERE sha1 = NEW.sha1;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS node__sha1__deorphan_update
|
||||
AFTER UPDATE OF sha1 ON node
|
||||
WHEN NEW.sha1 IS NOT NULL
|
||||
BEGIN
|
||||
DELETE FROM orphan WHERE sha1 = NEW.sha1;
|
||||
END;
|
||||
|
||||
--
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tag_space(
|
||||
id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
CHECK (name NOT LIKE '%:%'),
|
||||
PRIMARY KEY (id)
|
||||
) STRICT;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS tag_space__name ON tag_space(name);
|
||||
|
||||
-- To avoid having to deal with NULLs, always create this special tag space.
|
||||
INSERT OR IGNORE INTO tag_space(id, name, description)
|
||||
VALUES(0, '', 'User-defined tags');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tag(
|
||||
id INTEGER NOT NULL,
|
||||
space INTEGER NOT NULL REFERENCES tag_space(id),
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
) STRICT;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS tag__space_name ON tag(space, name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tag_assignment(
|
||||
sha1 TEXT NOT NULL REFERENCES image(sha1),
|
||||
tag INTEGER NOT NULL REFERENCES tag(id),
|
||||
weight REAL NOT NULL, -- 0..1 normalized weight assigned to tag
|
||||
PRIMARY KEY (sha1, tag)
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS tag_assignment__tag ON tag_assignment(tag);
|
675
public/gallery.js
Normal file
675
public/gallery.js
Normal file
@ -0,0 +1,675 @@
|
||||
'use strict'
|
||||
|
||||
let callActive = false
|
||||
let callFaulty = false
|
||||
|
||||
function call(method, params) {
|
||||
// XXX: At least with POST, unsuccessful requests result
|
||||
// in catched errors containing Errors with a null message.
|
||||
// This is an issue within XMLHttpRequest.
|
||||
callActive++
|
||||
return m.request({
|
||||
method: "POST",
|
||||
url: `/api/${method}`,
|
||||
body: params,
|
||||
}).then(result => {
|
||||
callActive--
|
||||
callFaulty = false
|
||||
return result
|
||||
}).catch(error => {
|
||||
callActive--
|
||||
callFaulty = true
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
const loading = (window.location.hostname !== 'localhost') ? 'lazy' : undefined
|
||||
|
||||
let Header = {
|
||||
global: [
|
||||
{name: "Browse", route: '/browse'},
|
||||
{name: "Tags", route: '/tags'},
|
||||
{name: "Duplicates", route: '/duplicates'},
|
||||
{name: "Orphans", route: '/orphans'},
|
||||
],
|
||||
|
||||
image: [
|
||||
{
|
||||
route: '/view',
|
||||
render: () => m(m.route.Link, {
|
||||
href: `/view/:key`,
|
||||
params: {key: m.route.param('key')},
|
||||
class: m.route.get().startsWith('/view')
|
||||
? 'active' : undefined,
|
||||
}, "View"),
|
||||
},
|
||||
{
|
||||
route: '/similar',
|
||||
render: () => m(m.route.Link, {
|
||||
href: `/similar/:key`,
|
||||
params: {key: m.route.param('key')},
|
||||
class: m.route.get().startsWith('/similar')
|
||||
? 'active' : undefined,
|
||||
}, "Similar"),
|
||||
},
|
||||
],
|
||||
|
||||
search: [
|
||||
{
|
||||
route: '/search',
|
||||
render: () => m(m.route.Link, {
|
||||
href: `/search/:key`,
|
||||
params: {key: m.route.param('key')},
|
||||
class: m.route.get().startsWith('/search')
|
||||
? 'active' : undefined,
|
||||
}, "Search"),
|
||||
},
|
||||
],
|
||||
|
||||
view(vnode) {
|
||||
const route = m.route.get()
|
||||
const main = this.global.map(x =>
|
||||
m(m.route.Link, {
|
||||
href: x.route,
|
||||
class: route.startsWith(x.route) ? 'active' : undefined,
|
||||
}, x.name))
|
||||
|
||||
let context
|
||||
if (this.image.some(x => route.startsWith(x.route)))
|
||||
context = this.image.map(x => x.render())
|
||||
if (this.search.some(x => route.startsWith(x.route)))
|
||||
context = this.search.map(x => x.render())
|
||||
|
||||
return m('.header', {}, [
|
||||
m('nav', main),
|
||||
m('nav', context),
|
||||
callFaulty
|
||||
? m('.activity.error[title=Error]', '●')
|
||||
: callActive
|
||||
? m('.activity[title=Busy]', '●')
|
||||
: m('.activity[title=Idle]', '○'),
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
let Thumbnail = {
|
||||
view(vnode) {
|
||||
const e = vnode.attrs.info
|
||||
if (!e.thumbW || !e.thumbH)
|
||||
return m('.thumbnail.missing', {...vnode.attrs, info: null})
|
||||
return m('img.thumbnail', {...vnode.attrs, info: null,
|
||||
src: `/thumb/${e.sha1}`, width: e.thumbW, height: e.thumbH,
|
||||
loading})
|
||||
},
|
||||
}
|
||||
|
||||
let ScoredTag = {
|
||||
view(vnode) {
|
||||
const {space, tagname, score} = vnode.attrs
|
||||
return m('li', [
|
||||
m("meter[max=1.0]", {value: score, title: score}, score),
|
||||
` `,
|
||||
m(m.route.Link, {
|
||||
href: `/search/:key`,
|
||||
params: {key: `${space}:${tagname}`},
|
||||
}, ` ${tagname}`),
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
let BrowseModel = {
|
||||
path: undefined,
|
||||
subdirectories: [],
|
||||
entries: [],
|
||||
collator: new Intl.Collator(undefined, {numeric: true}),
|
||||
|
||||
async reload(path) {
|
||||
if (this.path !== path) {
|
||||
this.path = path
|
||||
this.subdirectories = []
|
||||
this.entries = []
|
||||
}
|
||||
|
||||
let resp = await call('browse', {path})
|
||||
this.subdirectories = resp.subdirectories
|
||||
this.entries = resp.entries.sort((a, b) =>
|
||||
this.collator.compare(a.name, b.name))
|
||||
},
|
||||
|
||||
joinPath(parent, child) {
|
||||
if (!parent)
|
||||
return child
|
||||
if (!child)
|
||||
return parent
|
||||
return `${parent}/${child}`
|
||||
},
|
||||
|
||||
getBrowseLinks() {
|
||||
if (this.path === undefined)
|
||||
return []
|
||||
|
||||
let links = [{name: "Root", path: "", level: -1}], path
|
||||
for (const crumb of this.path.split('/').filter(s => !!s)) {
|
||||
path = this.joinPath(path, crumb)
|
||||
links.push({name: crumb, path: path, level: -1})
|
||||
}
|
||||
|
||||
links[links.length - 1].level = 0
|
||||
|
||||
for (const sub of this.subdirectories) {
|
||||
links.push(
|
||||
{name: sub, path: this.joinPath(this.path, sub), level: +1})
|
||||
}
|
||||
return links
|
||||
},
|
||||
}
|
||||
|
||||
let BrowseBarLink = {
|
||||
view(vnode) {
|
||||
const link = vnode.attrs.link
|
||||
|
||||
let c = 'selected'
|
||||
if (link.level < 0)
|
||||
c = 'parent'
|
||||
if (link.level > 0)
|
||||
c = 'child'
|
||||
|
||||
return m('li', {
|
||||
class: c,
|
||||
}, m(m.route.Link, {
|
||||
href: `/browse/:key`,
|
||||
params: {key: link.path},
|
||||
}, link.name))
|
||||
},
|
||||
}
|
||||
|
||||
let BrowseView = {
|
||||
// So that Page Up/Down, etc., work after changing directories.
|
||||
// Programmatically focusing a scrollable element requires setting tabindex,
|
||||
// and causes :focus-visible on page load, which we suppress in CSS.
|
||||
// I wish there was another way, but the workaround isn't particularly bad.
|
||||
// focus({focusVisible: true}) is FF 104+ only and experimental.
|
||||
oncreate(vnode) { vnode.dom.focus() },
|
||||
|
||||
view(vnode) {
|
||||
return m('.browser[tabindex=0]', {
|
||||
// Trying to force the oncreate on path changes.
|
||||
key: BrowseModel.path,
|
||||
}, BrowseModel.entries.map(info => {
|
||||
return m(m.route.Link, {href: `/view/${info.sha1}`},
|
||||
m(Thumbnail, {info, title: info.name}))
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
let Browse = {
|
||||
// Reload the model immediately, to improve responsivity.
|
||||
// But we don't need to: https://mithril.js.org/route.html#preloading-data
|
||||
// Also see: https://mithril.js.org/route.html#route-cancellation--blocking
|
||||
oninit(vnode) {
|
||||
let path = vnode.attrs.key || ""
|
||||
BrowseModel.reload(path)
|
||||
},
|
||||
|
||||
view(vnode) {
|
||||
return m('.container', {}, [
|
||||
m(Header),
|
||||
m('.body', {}, [
|
||||
m('.sidebar', [
|
||||
m('ul.path', BrowseModel.getBrowseLinks()
|
||||
.map(link => m(BrowseBarLink, {link}))),
|
||||
]),
|
||||
m(BrowseView),
|
||||
]),
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
let TagsModel = {
|
||||
ns: null,
|
||||
namespaces: {},
|
||||
|
||||
async reload(ns) {
|
||||
if (this.ns !== ns) {
|
||||
this.ns = ns
|
||||
this.namespaces = {}
|
||||
}
|
||||
|
||||
this.namespaces = await call('tags', {namespace: ns})
|
||||
},
|
||||
}
|
||||
|
||||
let TagsList = {
|
||||
view(vnode) {
|
||||
// TODO: Make it possible to sort by count.
|
||||
const tags = Object.entries(vnode.attrs.tags)
|
||||
.sort(([a, b]) => a[0].localeCompare(b[0]))
|
||||
|
||||
return (tags.length == 0)
|
||||
? "No tags"
|
||||
: m("ul", tags.map(([name, count]) => m("li", [
|
||||
m(m.route.Link, {
|
||||
href: `/search/:key`,
|
||||
params: {key: `${vnode.attrs.space}:${name}`},
|
||||
}, ` ${name}`),
|
||||
` ×${count}`,
|
||||
])))
|
||||
},
|
||||
}
|
||||
|
||||
let TagsView = {
|
||||
// See BrowseView.
|
||||
oncreate(vnode) { vnode.dom.focus() },
|
||||
|
||||
view(vnode) {
|
||||
// XXX: The empty-named tag namespace gets a bit shafted,
|
||||
// in particular in the router, as well as with its header.
|
||||
// Maybe we could refer to it by its numeric ID in routing.
|
||||
const names = Object.keys(TagsModel.namespaces)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
|
||||
let children = (names.length == 0)
|
||||
? "No namespaces"
|
||||
: names.map(space => {
|
||||
const ns = TagsModel.namespaces[space]
|
||||
return [
|
||||
m("h2", space),
|
||||
ns.description ? m("p", ns.description) : [],
|
||||
m(TagsList, {space, tags: ns.tags}),
|
||||
]
|
||||
})
|
||||
return m('.tags[tabindex=0]', {}, children)
|
||||
},
|
||||
}
|
||||
|
||||
let Tags = {
|
||||
oninit(vnode) {
|
||||
let ns = vnode.attrs.key
|
||||
TagsModel.reload(ns)
|
||||
},
|
||||
|
||||
view(vnode) {
|
||||
return m('.container', {}, [
|
||||
m(Header),
|
||||
m('.body', {}, m(TagsView)),
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
let DuplicatesModel = {
|
||||
entries: [],
|
||||
|
||||
async reload() {
|
||||
this.entries = await call('duplicates', {})
|
||||
},
|
||||
}
|
||||
|
||||
let DuplicatesThumbnail = {
|
||||
view(vnode) {
|
||||
const info = vnode.attrs.info
|
||||
return [
|
||||
m(m.route.Link, {href: `/similar/${info.sha1}`},
|
||||
m(Thumbnail, {info})),
|
||||
(info.occurences != 1) ? ` ×${info.occurences}` : [],
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
let DuplicatesList = {
|
||||
// See BrowseView.
|
||||
oncreate(vnode) { vnode.dom.focus() },
|
||||
|
||||
view(vnode) {
|
||||
let children = (DuplicatesModel.entries.length == 0)
|
||||
? "No duplicates"
|
||||
: DuplicatesModel.entries.map(group =>
|
||||
m('.row', group.map(entry =>
|
||||
m(DuplicatesThumbnail, {info: entry}))))
|
||||
return m('.duplicates[tabindex=0]', {}, children)
|
||||
},
|
||||
}
|
||||
|
||||
let Duplicates = {
|
||||
oninit(vnode) {
|
||||
DuplicatesModel.reload()
|
||||
},
|
||||
|
||||
view(vnode) {
|
||||
return m('.container', {}, [
|
||||
m(Header),
|
||||
m('.body', {}, m(DuplicatesList)),
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
let OrphansModel = {
|
||||
entries: [],
|
||||
|
||||
async reload() {
|
||||
this.entries = await call('orphans', {})
|
||||
},
|
||||
}
|
||||
|
||||
let OrphansReplacement = {
|
||||
view(vnode) {
|
||||
const info = vnode.attrs.info
|
||||
if (!info)
|
||||
return []
|
||||
|
||||
return [
|
||||
` → `,
|
||||
m(m.route.Link, {href: `/view/${info.sha1}`},
|
||||
m(Thumbnail, {info})),
|
||||
`${info.tags} tags`,
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
let OrphansRow = {
|
||||
view(vnode) {
|
||||
const info = vnode.attrs.info
|
||||
return m('.row', [
|
||||
// It might not load, but still allow tag viewing.
|
||||
m(m.route.Link, {href: `/view/${info.sha1}`},
|
||||
m(Thumbnail, {info})),
|
||||
`${info.tags} tags`,
|
||||
m(OrphansReplacement, {info: info.replacement}),
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
let OrphansList = {
|
||||
// See BrowseView.
|
||||
oncreate(vnode) { vnode.dom.focus() },
|
||||
|
||||
view(vnode) {
|
||||
let children = (OrphansModel.entries.length == 0)
|
||||
? "No orphans"
|
||||
: OrphansModel.entries.map(info => [
|
||||
m("h2", info.lastPath),
|
||||
m(OrphansRow, {info}),
|
||||
])
|
||||
return m('.orphans[tabindex=0]', {}, children)
|
||||
},
|
||||
}
|
||||
|
||||
let Orphans = {
|
||||
oninit(vnode) {
|
||||
OrphansModel.reload()
|
||||
},
|
||||
|
||||
view(vnode) {
|
||||
return m('.container', {}, [
|
||||
m(Header),
|
||||
m('.body', {}, m(OrphansList)),
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
let ViewModel = {
|
||||
sha1: undefined,
|
||||
width: 0,
|
||||
height: 0,
|
||||
paths: [],
|
||||
tags: {},
|
||||
|
||||
async reload(sha1) {
|
||||
if (this.sha1 !== sha1) {
|
||||
this.sha1 = sha1
|
||||
this.width = this.height = 0
|
||||
this.paths = []
|
||||
this.tags = {}
|
||||
}
|
||||
|
||||
let resp = await call('info', {sha1: sha1})
|
||||
this.width = resp.width
|
||||
this.height = resp.height
|
||||
this.paths = resp.paths
|
||||
this.tags = resp.tags
|
||||
},
|
||||
}
|
||||
|
||||
let ViewBarBrowseLink = {
|
||||
view(vnode) {
|
||||
return m(m.route.Link, {
|
||||
href: `/browse/:key`,
|
||||
params: {key: vnode.attrs.path},
|
||||
}, vnode.attrs.name)
|
||||
},
|
||||
}
|
||||
|
||||
let ViewBarPath = {
|
||||
view(vnode) {
|
||||
const parents = vnode.attrs.path.split('/')
|
||||
const basename = parents.pop()
|
||||
|
||||
let result = [], path
|
||||
if (!parents.length)
|
||||
result.push(m(ViewBarBrowseLink, {path: "", name: "Root"}), "/")
|
||||
for (const crumb of parents) {
|
||||
path = BrowseModel.joinPath(path, crumb)
|
||||
result.push(m(ViewBarBrowseLink, {path, name: crumb}), "/")
|
||||
}
|
||||
result.push(basename)
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
let ViewBar = {
|
||||
view(vnode) {
|
||||
return m('.viewbar', [
|
||||
m('h2', "Locations"),
|
||||
m('ul', ViewModel.paths.map(path =>
|
||||
m('li', m(ViewBarPath, {path})))),
|
||||
m('h2', "Tags"),
|
||||
Object.entries(ViewModel.tags).map(([space, tags]) => [
|
||||
m("h3", m(m.route.Link, {href: `/tags/${space}`}, space)),
|
||||
m("ul.tags", Object.entries(tags)
|
||||
.sort(([t1, w1], [t2, w2]) => (w2 - w1))
|
||||
.map(([tag, score]) =>
|
||||
m(ScoredTag, {space, tagname: tag, score}))),
|
||||
]),
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
let View = {
|
||||
oninit(vnode) {
|
||||
let sha1 = vnode.attrs.key || ""
|
||||
ViewModel.reload(sha1)
|
||||
},
|
||||
|
||||
view(vnode) {
|
||||
const view = m('.view', [
|
||||
ViewModel.sha1 !== undefined
|
||||
? m('img', {src: `/image/${ViewModel.sha1}`,
|
||||
width: ViewModel.width, height: ViewModel.height})
|
||||
: "No image.",
|
||||
])
|
||||
return m('.container', {}, [
|
||||
m(Header),
|
||||
m('.body', {}, [view, m(ViewBar)]),
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
let SimilarModel = {
|
||||
sha1: undefined,
|
||||
info: {paths: []},
|
||||
groups: {},
|
||||
|
||||
async reload(sha1) {
|
||||
if (this.sha1 !== sha1) {
|
||||
this.sha1 = sha1
|
||||
this.info = {paths: []}
|
||||
this.groups = {}
|
||||
}
|
||||
|
||||
let resp = await call('similar', {sha1: sha1})
|
||||
this.info = resp.info
|
||||
this.groups = resp.groups
|
||||
},
|
||||
}
|
||||
|
||||
let SimilarThumbnail = {
|
||||
view(vnode) {
|
||||
const info = vnode.attrs.info
|
||||
return m(m.route.Link, {href: `/view/${info.sha1}`},
|
||||
m(Thumbnail, {info}))
|
||||
},
|
||||
}
|
||||
|
||||
let SimilarGroup = {
|
||||
view(vnode) {
|
||||
const images = vnode.attrs.images
|
||||
let result = [
|
||||
m('h2', vnode.attrs.name),
|
||||
images.map(info => m('.row', [
|
||||
m(SimilarThumbnail, {info}),
|
||||
m('ul', [
|
||||
m('li', Math.round(info.pixelsRatio * 100) +
|
||||
"% pixels of input image"),
|
||||
info.paths.map(path =>
|
||||
m('li', m(ViewBarPath, {path}))),
|
||||
]),
|
||||
]))
|
||||
]
|
||||
if (!images.length)
|
||||
result.push("No matches.")
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
let SimilarList = {
|
||||
view(vnode) {
|
||||
if (SimilarModel.sha1 === undefined ||
|
||||
SimilarModel.info.paths.length == 0)
|
||||
return "No image"
|
||||
|
||||
const info = SimilarModel.info
|
||||
return m('.similar', {}, [
|
||||
m('.row', [
|
||||
m(SimilarThumbnail, {info}),
|
||||
m('ul', info.paths.map(path =>
|
||||
m('li', m(ViewBarPath, {path})))),
|
||||
]),
|
||||
Object.entries(SimilarModel.groups).map(([name, images]) =>
|
||||
m(SimilarGroup, {name, images})),
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
let Similar = {
|
||||
oninit(vnode) {
|
||||
let sha1 = vnode.attrs.key || ""
|
||||
SimilarModel.reload(sha1)
|
||||
},
|
||||
|
||||
view(vnode) {
|
||||
return m('.container', {}, [
|
||||
m(Header),
|
||||
m('.body', {}, m(SimilarList)),
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
let SearchModel = {
|
||||
query: undefined,
|
||||
matches: [],
|
||||
related: {},
|
||||
|
||||
async reload(query) {
|
||||
if (this.query !== query) {
|
||||
this.query = query
|
||||
this.matches = []
|
||||
this.related = {}
|
||||
}
|
||||
|
||||
let resp = await call('search', {query})
|
||||
this.matches = resp.matches
|
||||
this.related = resp.related
|
||||
},
|
||||
}
|
||||
|
||||
let SearchRelated = {
|
||||
view(vnode) {
|
||||
return Object.entries(SearchModel.related)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([space, tags]) => [
|
||||
m('h2', space),
|
||||
m('ul.tags', tags
|
||||
.sort((a, b) => (b.score - a.score))
|
||||
.map(({tag, score}) =>
|
||||
m(ScoredTag, {space, tagname: tag, score}))),
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
let SearchView = {
|
||||
// See BrowseView.
|
||||
oncreate(vnode) { vnode.dom.focus() },
|
||||
|
||||
view(vnode) {
|
||||
return m('.browser[tabindex=0]', {
|
||||
// Trying to force the oncreate on path changes.
|
||||
key: SearchModel.path,
|
||||
}, SearchModel.matches
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(info => {
|
||||
return m(m.route.Link, {href: `/view/${info.sha1}`},
|
||||
m(Thumbnail, {info, title: info.score}))
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
let Search = {
|
||||
oninit(vnode) {
|
||||
SearchModel.reload(vnode.attrs.key)
|
||||
},
|
||||
|
||||
view(vnode) {
|
||||
return m('.container', {}, [
|
||||
m(Header),
|
||||
m('.body', {}, [
|
||||
m('.sidebar', [
|
||||
m('p', SearchModel.query),
|
||||
m(SearchRelated),
|
||||
]),
|
||||
m(SearchView),
|
||||
]),
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
m.route(document.body, "/browse/", {
|
||||
// The path doesn't need to be escaped, perhaps change that (":key...").
|
||||
"/browse/": Browse,
|
||||
"/browse/:key": Browse,
|
||||
"/tags": Tags,
|
||||
"/tags/:key": Tags,
|
||||
"/duplicates": Duplicates,
|
||||
"/orphans": Orphans,
|
||||
|
||||
"/view/:key": View,
|
||||
"/similar/:key": Similar,
|
||||
|
||||
"/search/:key": Search,
|
||||
})
|
||||
})
|
102
public/style.css
Normal file
102
public/style.css
Normal file
@ -0,0 +1,102 @@
|
||||
:root { --shade-color: #eee; }
|
||||
|
||||
body { margin: 0; padding: 0; font-family: sans-serif; }
|
||||
a { color: inherit; }
|
||||
|
||||
.container { display: flex; flex-direction: column;
|
||||
height: 100vh; width: 100vw; overflow: hidden; }
|
||||
|
||||
.body { display: flex; flex-grow: 1; overflow: hidden; position: relative; }
|
||||
.body::after { content: ''; position: absolute; pointer-events: none;
|
||||
top: 0; left: 0; right: 0; height: .75rem;
|
||||
background: linear-gradient(#fff, rgb(255 255 255 / 0%)); }
|
||||
|
||||
.header { color: #000; background: #aaa linear-gradient(#888, #999);
|
||||
display: flex; justify-content: space-between; column-gap: .5rem; }
|
||||
.header nav { display: flex; margin: 0 .5rem; align-items: end; }
|
||||
.header nav a { display: block; text-decoration: none;
|
||||
background: #bbb linear-gradient(#bbb, #ccc);
|
||||
margin: .25rem 0 0 -1px; padding: .25rem .75rem;
|
||||
border: 1px solid #888; border-radius: .5rem .5rem 0 0; }
|
||||
.header nav a.active { font-weight: bold; border-bottom: 1px solid #fff;
|
||||
background: #fff linear-gradient(#eee, #fff); }
|
||||
.header nav a.active, .header nav a:hover { padding-bottom: .4rem; }
|
||||
.header .activity { padding: .25rem .5rem; align-self: center; color: #fff; }
|
||||
.header .activity.error { color: #f00; }
|
||||
|
||||
.sidebar { padding: .25rem .5rem; background: var(--shade-color);
|
||||
border-right: 1px solid #ccc; overflow: auto;
|
||||
min-width: 10rem; max-width: 20rem; flex-shrink: 0; }
|
||||
.sidebar h2 { margin: 0.5em 0 0.25em 0; padding: 0; font-size: 1.2rem; }
|
||||
.sidebar ul { margin: .5rem 0; padding: 0; }
|
||||
|
||||
.sidebar .path { margin: .5rem -.5rem; }
|
||||
.sidebar .path li { margin: 0; padding: 0; }
|
||||
.sidebar .path li a { padding: .25rem .5rem; padding-left: 30px;
|
||||
display: block; text-decoration: none; white-space: nowrap; }
|
||||
.sidebar .path li a:hover { background-color: rgb(0 0 0 / 10%); }
|
||||
|
||||
.sidebar .path li.parent a {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Cpath d='M 4 14 10 8 16 14' stroke='%23888' stroke-width='4' fill='none' /%3E%3C/svg%3E%0A");
|
||||
background-repeat: no-repeat; background-position: 5px center; }
|
||||
|
||||
.sidebar .path li.selected a { font-weight: bold;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Ccircle cx='10' cy='10' r='6' fill='%23888' /%3E%3C/svg%3E%0A");
|
||||
background-repeat: no-repeat; background-position: 5px center; }
|
||||
|
||||
.sidebar .path li.child a {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Cpath d='M 4 6 10 12 16 6' stroke='%23888' stroke-width='4' fill='none' /%3E%3C/svg%3E%0A");
|
||||
background-repeat: no-repeat; background-position: 5px center; }
|
||||
|
||||
.browser { overflow: auto; display: flex; flex-wrap: wrap;
|
||||
align-content: flex-start; justify-content: center; align-items: center;
|
||||
gap: 3px; padding: 9px; flex-grow: 1; }
|
||||
.browser:focus-visible { outline: 0; box-shadow: none; }
|
||||
|
||||
.tags { padding: .5rem; flex-grow: 1; overflow: auto; }
|
||||
.tags:focus-visible { outline: 0; box-shadow: none; }
|
||||
.tags h2 { margin: .5em 0 .25em 0; padding: 0; font-size: 1.1rem; }
|
||||
.tags p { margin: .25em 0; }
|
||||
.tags ul { display: flex; margin: .5em 0; padding: 0;
|
||||
flex-wrap: wrap; gap: .25em; }
|
||||
.tags ul li { display: block; margin: 0; padding: .25em .5em;
|
||||
border-radius: .5rem; background: var(--shade-color); }
|
||||
|
||||
img.thumbnail { display: block;
|
||||
background: repeating-conic-gradient(#eee 0% 25%, transparent 0% 50%)
|
||||
50% / 20px 20px; }
|
||||
img.thumbnail, .thumbnail.missing { box-shadow: 0 0 3px rgba(0, 0, 0, 0.75);
|
||||
margin: 3px; border: 0px solid #000; }
|
||||
.thumbnail.missing { width: 128px; height: 128px; position: relative; }
|
||||
.thumbnail.missing::after { content: '?'; font-size: 64px;
|
||||
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); }
|
||||
|
||||
.view { display: flex; flex-grow: 1; overflow: hidden;
|
||||
justify-content: center; align-items: center; }
|
||||
.view img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.view img { z-index: 1; }
|
||||
|
||||
.viewbar { padding: .25rem .5rem; background: #eee;
|
||||
border-left: 1px solid #ccc; min-width: 20rem; overflow: auto; }
|
||||
.viewbar h2 { margin: 0.5em 0 0.25em 0; padding: 0; font-size: 1.2rem; }
|
||||
.viewbar h3 { margin: 0.25em 0; padding: 0; font-size: 1.1rem; }
|
||||
.viewbar ul { margin: 0; padding: 0 0 0 1.25em; list-style-type: "- "; }
|
||||
.viewbar ul.tags { padding: 0; list-style-type: none; }
|
||||
.viewbar li { margin: 0; padding: 0; }
|
||||
|
||||
.sidebar meter,
|
||||
.viewbar meter { width: 1.25rem;
|
||||
/* background: white; border: 1px solid #ccc; */ }
|
||||
|
||||
.similar { padding: .5rem; flex-grow: 1; overflow: auto; }
|
||||
.similar h2 { margin: 1em 0 0.5em 0; padding: 0; font-size: 1.2rem; }
|
||||
.similar .row { display: flex; margin: .5rem 0; }
|
||||
.similar .row ul { margin: 0; padding: 0 0 0 1.25em; list-style-type: "- "; }
|
||||
|
||||
.duplicates,
|
||||
.orphans { padding: .5rem; flex-grow: 1; overflow: auto; }
|
||||
.duplicates .row,
|
||||
.orphans .row { display: flex; margin: .5rem 0; align-items: center; gap: 3px; }
|
||||
|
||||
.orphans .row { margin-bottom: 1.25rem; }
|
||||
.orphans h2 { margin: 0.25em 0; padding: 0; font-size: 1.1rem; }
|
65
test.sh
Executable file
65
test.sh
Executable file
@ -0,0 +1,65 @@
|
||||
#!/bin/sh -xe
|
||||
cd "$(dirname "$0")"
|
||||
make gallery
|
||||
target=/tmp/G input=/tmp/G/Test
|
||||
rm -rf $target
|
||||
|
||||
mkdir -p $target $input/Test $input/Empty
|
||||
gen() { magick "$@"; sha1=$(sha1sum "$(eval echo \$\{$#\})" | cut -d' ' -f1); }
|
||||
|
||||
gen wizard: $input/wizard.webp
|
||||
gen -seed 10 -size 256x256 plasma:fractal \
|
||||
$input/Test/dhash.jpg
|
||||
gen -seed 10 -size 256x256 plasma:fractal \
|
||||
$input/Test/dhash.png
|
||||
sha1duplicate=$sha1
|
||||
cp $input/Test/dhash.png \
|
||||
$input/Test/multiple-paths.png
|
||||
|
||||
gen -seed 20 -size 160x128 plasma:fractal \
|
||||
-bordercolor transparent -border 64 \
|
||||
$input/Test/transparent-wide.png
|
||||
gen -seed 30 -size 1024x256 plasma:fractal \
|
||||
-alpha set -channel A -evaluate multiply 0.2 \
|
||||
$input/Test/translucent-superwide.png
|
||||
|
||||
gen -size 96x96 -delay 10 -loop 0 \
|
||||
-seed 111 plasma:fractal \
|
||||
-seed 222 plasma:fractal \
|
||||
-seed 333 plasma:fractal \
|
||||
-seed 444 plasma:fractal \
|
||||
-seed 555 plasma:fractal \
|
||||
-seed 666 plasma:fractal \
|
||||
$input/Test/animation-small.gif
|
||||
sha1animated=$sha1
|
||||
gen $input/Test/animation-small.gif \
|
||||
$input/Test/video.mp4
|
||||
|
||||
./gallery init $target
|
||||
./gallery sync $target $input "$@"
|
||||
./gallery thumbnail $target
|
||||
./gallery dhash $target
|
||||
./gallery tag $target test "Test space" <<-END
|
||||
$sha1duplicate foo 1.0
|
||||
$sha1duplicate bar 0.5
|
||||
$sha1animated foo 0.8
|
||||
END
|
||||
|
||||
# TODO: Test all the various possible sync transitions.
|
||||
mv $input/Test $input/Plasma
|
||||
./gallery sync $target $input
|
||||
|
||||
./gallery web $target :8080 &
|
||||
web=$!
|
||||
trap "kill $web; wait $web" EXIT INT TERM
|
||||
sleep 0.25
|
||||
|
||||
call() (curl http://localhost:8080/api/$1 -X POST --data-binary @-)
|
||||
|
||||
# TODO: Verify that things are how we expect them to be.
|
||||
echo '{"path":"'"$(basename "$input")"'"}' | call browse
|
||||
echo '{}' | call tags
|
||||
echo '{}' | call duplicates
|
||||
echo '{}' | call orphans
|
||||
echo '{"sha1":"'"$sha1duplicate"'"}' | call info
|
||||
echo '{"sha1":"'"$sha1duplicate"'"}' | call similar
|
Loading…
Reference in New Issue
Block a user