Initial commit
This commit is contained in:
commit
054078908a
|
@ -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.
|
|
@ -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)
|
|
@ -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.
|
|
@ -0,0 +1,6 @@
|
||||||
|
#!/bin/sh -e
|
||||||
|
gofmt <<EOF
|
||||||
|
package ${GOPACKAGE:-main}
|
||||||
|
|
||||||
|
const initializeSQL = \`$(sed 's/`/` + "`" + `/g' "$@")\`
|
||||||
|
EOF
|
|
@ -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
|
||||||
|
)
|
|
@ -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=
|
|
@ -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);
|
|
@ -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,
|
||||||
|
})
|
||||||
|
})
|
|
@ -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; }
|
|
@ -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