Initial commit

This commit is contained in:
Přemysl Eric Janouch 2023-12-08 02:16:04 +01:00
commit 054078908a
Signed by: p
GPG Key ID: A0420B94F92B9493
11 changed files with 3502 additions and 0 deletions

12
LICENSE Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,6 @@
#!/bin/sh -e
gofmt <<EOF
package ${GOPACKAGE:-main}
const initializeSQL = \`$(sed 's/`/` + "`" + `/g' "$@")\`
EOF

8
go.mod Normal file
View 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
View 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
View 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);

2497
main.go Normal file

File diff suppressed because it is too large Load Diff

675
public/gallery.js Normal file
View 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
View 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
View 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