Compare commits
17 Commits
5e0e9f8a42
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
181ab5a8e7
|
|||
|
fd192310c7
|
|||
|
b73e0b4622
|
|||
|
0530c5d95f
|
|||
|
ce2e58b6bc
|
|||
|
ca462ac005
|
|||
|
e895beadb7
|
|||
|
615af97043
|
|||
|
595db869e5
|
|||
|
537b48dc22
|
|||
|
2c09745a9f
|
|||
|
beb7c5e337
|
|||
|
19705527a0
|
|||
|
9e22bd0e20
|
|||
|
d27d8655bb
|
|||
|
6d75ec60bf
|
|||
|
84a94933b3
|
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/gallery
|
||||
/initialize.go
|
||||
/public/mithril.js
|
||||
|
||||
/gallery.cflags
|
||||
/gallery.config
|
||||
/gallery.creator
|
||||
/gallery.creator.user
|
||||
/gallery.cxxflags
|
||||
/gallery.files
|
||||
/gallery.includes
|
||||
28
README.adoc
28
README.adoc
@@ -8,10 +8,32 @@ 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 v7, xdg-utils
|
||||
|
||||
The gallery is designed for simplicity, and easy interoperability.
|
||||
sqlite3, curl, jq, and the filesystem will take you a long way.
|
||||
|
||||
Prerequisites: Go, ImageMagick, xdg-utils
|
||||
|
||||
ImageMagick v7 is preferred, it doesn't shoot out of memory as often.
|
||||
|
||||
Getting it to work
|
||||
------------------
|
||||
# apt install build-essential git golang imagemagick xdg-utils
|
||||
$ git clone https://git.janouch.name/p/gallery.git
|
||||
$ cd gallery
|
||||
$ make
|
||||
$ ./gallery init G
|
||||
$ ./gallery sync G ~/Pictures
|
||||
$ ./gallery thumbnail G # parallelized, with memory limits
|
||||
$ ./gallery -threads 1 thumbnail G # one thread only gets more memory
|
||||
$ ./gallery dhash G
|
||||
$ ./gallery web G :8080
|
||||
|
||||
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.
|
||||
batches in a cron job, or from a systemd timer.
|
||||
|
||||
The _web_ command needs to see the _public_ directory,
|
||||
and is friendly to reverse proxying.
|
||||
|
||||
Demo
|
||||
----
|
||||
https://holedigging.club/gallery/
|
||||
|
||||
@@ -53,7 +53,7 @@ Tagging galleries
|
||||
The appropriate invocation depends on your machine, and the chosen model.
|
||||
Unless you have a powerful machine, or use a fast model, it may take forever.
|
||||
|
||||
$ find "$GALLERY/images" -type f \
|
||||
$ find "$GALLERY/images" -type l \
|
||||
| build/deeptagger --pipe -b 16 -t 0.5 \
|
||||
models/ml_caformer_m36_dec-5-97527.model \
|
||||
| sed 's|[^\t]*/||' \
|
||||
|
||||
@@ -28,11 +28,9 @@ run() {
|
||||
for model in models/*.model
|
||||
do
|
||||
name=$(sed -n 's/^name=//p' "$model")
|
||||
run "" 1 "$model" "$@"
|
||||
run "" 4 "$model" "$@"
|
||||
run "" 16 "$model" "$@"
|
||||
|
||||
run --cpu 1 "$model" "$@"
|
||||
run --cpu 4 "$model" "$@"
|
||||
run --cpu 16 "$model" "$@"
|
||||
for batch in 1 4 16
|
||||
do
|
||||
run "" $batch "$model" "$@"
|
||||
run --cpu $batch "$model" "$@"
|
||||
done
|
||||
done
|
||||
|
||||
@@ -315,6 +315,7 @@ run(std::vector<Magick::Image> &images, const Config &config,
|
||||
}
|
||||
}
|
||||
}
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
@@ -23,6 +23,7 @@ CREATE TABLE IF NOT EXISTS node(
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS node__sha1 ON node(sha1);
|
||||
CREATE INDEX IF NOT EXISTS node__parent ON node(parent);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS node__parent_name
|
||||
ON node(IFNULL(parent, 0), name);
|
||||
|
||||
|
||||
199
main.go
199
main.go
@@ -94,10 +94,15 @@ func init() {
|
||||
}
|
||||
|
||||
func openDB(directory string) error {
|
||||
galleryDirectory = directory
|
||||
|
||||
var err error
|
||||
db, err = sql.Open("sqlite3_custom", "file:"+filepath.Join(directory,
|
||||
nameOfDB+"?_foreign_keys=1&_busy_timeout=1000"))
|
||||
galleryDirectory = directory
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.Exec(initializeSQL)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -296,11 +301,10 @@ func cmdInit(fs *flag.FlagSet, args []string) error {
|
||||
if fs.NArg() != 1 {
|
||||
return errWrongUsage
|
||||
}
|
||||
if err := openDB(fs.Arg(0)); err != nil {
|
||||
if err := os.MkdirAll(fs.Arg(0), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(initializeSQL); err != nil {
|
||||
if err := openDB(fs.Arg(0)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -654,7 +658,9 @@ func getOrphanReplacement(webPath string) (*webOrphanImage, error) {
|
||||
}
|
||||
|
||||
parent, err := idForDirectoryPath(tx, path[:len(path)-1], false)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -681,7 +687,8 @@ func getOrphans() (result []webOrphan, err error) {
|
||||
FROM orphan AS o
|
||||
JOIN image AS i ON o.sha1 = i.sha1
|
||||
LEFT JOIN tag_assignment AS ta ON o.sha1 = ta.sha1
|
||||
GROUP BY o.sha1`)
|
||||
GROUP BY o.sha1
|
||||
ORDER BY path`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -838,15 +845,17 @@ type webSimilarImage struct {
|
||||
|
||||
func getSimilar(sha1 string, dhash int64, pixels int64, distance int) (
|
||||
result []webSimilarImage, err error) {
|
||||
// For distance ∈ {0, 1}, this query is quite inefficient.
|
||||
// In exchange, it's generic.
|
||||
//
|
||||
// If there's a dhash, there should also be thumbnail dimensions,
|
||||
// so not bothering with IFNULL on them.
|
||||
rows, err := db.Query(`
|
||||
SELECT sha1, width * height, IFNULL(thumbw, 0), IFNULL(thumbh, 0)
|
||||
FROM image WHERE sha1 <> ? AND dhash IS NOT NULL
|
||||
AND hamming(dhash, ?) = ?`, sha1, dhash, distance)
|
||||
// If there's a dhash, there should also be thumbnail dimensions.
|
||||
var rows *sql.Rows
|
||||
common := `SELECT sha1, width * height, IFNULL(thumbw, 0), IFNULL(thumbh, 0)
|
||||
FROM image WHERE sha1 <> ? AND `
|
||||
if distance == 0 {
|
||||
rows, err = db.Query(common+`dhash = ?`, sha1, dhash)
|
||||
} else {
|
||||
// This is generic, but quite inefficient for distance ∈ {0, 1}.
|
||||
rows, err = db.Query(common+`dhash IS NOT NULL
|
||||
AND hamming(dhash, ?) = ?`, sha1, dhash, distance)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1283,6 +1292,9 @@ type syncContext struct {
|
||||
stmtDisposeSub *sql.Stmt
|
||||
stmtDisposeAll *sql.Stmt
|
||||
|
||||
// exclude specifies filesystem paths that should be seen as missing.
|
||||
exclude *regexp.Regexp
|
||||
|
||||
// linked tracks which image hashes we've checked so far in the run.
|
||||
linked map[string]struct{}
|
||||
}
|
||||
@@ -1377,7 +1389,7 @@ func syncIsImage(path string) (bool, error) {
|
||||
}
|
||||
|
||||
func syncPingImage(path string) (int, int, error) {
|
||||
out, err := exec.Command("magick", "identify", "-limit", "thread", "1",
|
||||
out, err := exec.Command("identify", "-limit", "thread", "1",
|
||||
"-ping", "-format", "%w %h", path+"[0]").Output()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
@@ -1691,6 +1703,12 @@ func syncDirectory(c *syncContext, dbParent int64, fsPath string) error {
|
||||
fs = nil
|
||||
}
|
||||
|
||||
if c.exclude != nil {
|
||||
fs = slices.DeleteFunc(fs, func(f syncFile) bool {
|
||||
return c.exclude.MatchString(filepath.Join(fsPath, f.fsName))
|
||||
})
|
||||
}
|
||||
|
||||
// Convert differences to a form more convenient for processing.
|
||||
iDB, iFS, pairs := 0, 0, []syncPair{}
|
||||
for iDB < len(db) && iFS < len(fs) {
|
||||
@@ -1866,9 +1884,21 @@ const disposeCTE = `WITH RECURSIVE
|
||||
HAVING count = total
|
||||
)`
|
||||
|
||||
type excludeRE struct{ re *regexp.Regexp }
|
||||
|
||||
func (re *excludeRE) String() string { return fmt.Sprintf("%v", re.re) }
|
||||
|
||||
func (re *excludeRE) Set(value string) error {
|
||||
var err error
|
||||
re.re, err = regexp.Compile(value)
|
||||
return err
|
||||
}
|
||||
|
||||
// cmdSync ensures the given (sub)roots are accurately reflected
|
||||
// in the database.
|
||||
func cmdSync(fs *flag.FlagSet, args []string) error {
|
||||
var exclude excludeRE
|
||||
fs.Var(&exclude, "exclude", "exclude paths matching regular expression")
|
||||
fullpaths := fs.Bool("fullpaths", false, "don't basename arguments")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
@@ -1906,7 +1936,7 @@ func cmdSync(fs *flag.FlagSet, args []string) error {
|
||||
}
|
||||
|
||||
c := syncContext{ctx: ctx, tx: tx, pb: newProgressBar(-1),
|
||||
linked: make(map[string]struct{})}
|
||||
exclude: exclude.re, linked: make(map[string]struct{})}
|
||||
defer c.pb.Stop()
|
||||
|
||||
if c.stmtOrphan, err = c.tx.Prepare(disposeCTE + `
|
||||
@@ -2002,6 +2032,88 @@ func cmdRemove(fs *flag.FlagSet, args []string) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// --- Forgetting --------------------------------------------------------------
|
||||
|
||||
// cmdForget is for purging orphaned images from the database.
|
||||
func cmdForget(fs *flag.FlagSet, args []string) error {
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() < 2 {
|
||||
return errWrongUsage
|
||||
}
|
||||
if err := openDB(fs.Arg(0)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Creating a temporary database seems justifiable in this case.
|
||||
_, err = tx.Exec(
|
||||
`CREATE TEMPORARY TABLE forgotten (sha1 TEXT PRIMARY KEY)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare(`INSERT INTO forgotten (sha1) VALUES (?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for _, sha1 := range fs.Args()[1:] {
|
||||
if _, err := stmt.Exec(sha1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := tx.Query(`DELETE FROM forgotten
|
||||
WHERE sha1 IN (SELECT sha1 FROM node)
|
||||
OR sha1 NOT IN (SELECT sha1 FROM image)
|
||||
RETURNING sha1`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var sha1 string
|
||||
if err := rows.Scan(&sha1); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("not an orphan or not known at all: %s", sha1)
|
||||
}
|
||||
if _, err = tx.Exec(`
|
||||
DELETE FROM tag_assignment WHERE sha1 IN (SELECT sha1 FROM forgotten);
|
||||
DELETE FROM orphan WHERE sha1 IN (SELECT sha1 FROM forgotten);
|
||||
DELETE FROM image WHERE sha1 IN (SELECT sha1 FROM forgotten);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err = tx.Query(`SELECT sha1 FROM forgotten`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var sha1 string
|
||||
if err := rows.Scan(&sha1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(imagePath(sha1)); err != nil &&
|
||||
!os.IsNotExist(err) {
|
||||
log.Printf("%s", err)
|
||||
}
|
||||
if err := os.Remove(thumbPath(sha1)); err != nil &&
|
||||
!os.IsNotExist(err) {
|
||||
log.Printf("%s", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// --- Tagging -----------------------------------------------------------------
|
||||
|
||||
// cmdTag mass imports tags from data passed on stdin as a TSV
|
||||
@@ -2124,36 +2236,54 @@ func collectFileListing(root string) (paths []string, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func checkFiles(root, suffix string, hashes []string) (bool, []string, error) {
|
||||
func checkFiles(gc bool,
|
||||
root, suffix string, hashes []string) (bool, []string, error) {
|
||||
db := hashesToFileListing(root, suffix, hashes)
|
||||
fs, err := collectFileListing(root)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
|
||||
iDB, iFS, ok, intersection := 0, 0, true, []string{}
|
||||
// There are two legitimate cases of FS-only database files:
|
||||
// 1. There is no code to unlink images at all
|
||||
// (although sync should create orphan records for everything).
|
||||
// 2. thumbnail: failures may result in an unreferenced garbage image.
|
||||
ok := true
|
||||
onlyDB := func(path string) {
|
||||
ok = false
|
||||
fmt.Printf("only in DB: %s\n", path)
|
||||
}
|
||||
onlyFS := func(path string) {
|
||||
if !gc {
|
||||
ok = false
|
||||
fmt.Printf("only in FS: %s\n", path)
|
||||
} else if err := os.Remove(path); err != nil {
|
||||
ok = false
|
||||
fmt.Printf("only in FS (removing failed): %s: %s\n", path, err)
|
||||
} else {
|
||||
fmt.Printf("only in FS (removing): %s\n", path)
|
||||
}
|
||||
}
|
||||
|
||||
iDB, iFS, intersection := 0, 0, []string{}
|
||||
for iDB < len(db) && iFS < len(fs) {
|
||||
if db[iDB] == fs[iFS] {
|
||||
intersection = append(intersection, db[iDB])
|
||||
iDB++
|
||||
iFS++
|
||||
} else if db[iDB] < fs[iFS] {
|
||||
ok = false
|
||||
fmt.Printf("only in DB: %s\n", db[iDB])
|
||||
onlyDB(db[iDB])
|
||||
iDB++
|
||||
} else {
|
||||
ok = false
|
||||
fmt.Printf("only in FS: %s\n", fs[iFS])
|
||||
onlyFS(fs[iFS])
|
||||
iFS++
|
||||
}
|
||||
}
|
||||
for _, path := range db[iDB:] {
|
||||
ok = false
|
||||
fmt.Printf("only in DB: %s\n", path)
|
||||
onlyDB(path)
|
||||
}
|
||||
for _, path := range fs[iFS:] {
|
||||
ok = false
|
||||
fmt.Printf("only in FS: %s\n", path)
|
||||
onlyFS(path)
|
||||
}
|
||||
return ok, intersection, nil
|
||||
}
|
||||
@@ -2201,6 +2331,7 @@ func checkHashes(paths []string) (bool, error) {
|
||||
// cmdCheck carries out various database consistency checks.
|
||||
func cmdCheck(fs *flag.FlagSet, args []string) error {
|
||||
full := fs.Bool("full", false, "verify image hashes")
|
||||
gc := fs.Bool("gc", false, "garbage collect database files")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2237,13 +2368,13 @@ func cmdCheck(fs *flag.FlagSet, args []string) error {
|
||||
|
||||
// This somewhat duplicates {image,thumb}Path().
|
||||
log.Println("checking SQL against filesystem")
|
||||
okImages, intersection, err := checkFiles(
|
||||
okImages, intersection, err := checkFiles(*gc,
|
||||
filepath.Join(galleryDirectory, nameOfImageRoot), "", allSHA1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
okThumbs, _, err := checkFiles(
|
||||
okThumbs, _, err := checkFiles(*gc,
|
||||
filepath.Join(galleryDirectory, nameOfThumbRoot), ".webp", thumbSHA1)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2252,11 +2383,11 @@ func cmdCheck(fs *flag.FlagSet, args []string) error {
|
||||
ok = false
|
||||
}
|
||||
|
||||
log.Println("checking for dead symlinks")
|
||||
log.Println("checking for dead symlinks (should become orphans on sync)")
|
||||
for _, path := range intersection {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
ok = false
|
||||
fmt.Printf("%s: %s\n", path, err)
|
||||
fmt.Printf("%s: %s\n", path, err.(*os.PathError).Unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2321,7 +2452,7 @@ func makeThumbnail(load bool, pathImage, pathThumb string) (
|
||||
//
|
||||
// TODO: See if we can optimize resulting WebP animations.
|
||||
// (Do -layers optimize* apply to this format at all?)
|
||||
cmd := exec.Command("magick", "-limit", "thread", "1",
|
||||
cmd := exec.Command("convert", "-limit", "thread", "1",
|
||||
|
||||
// Do not invite the OOM killer, a particularly unpleasant guest.
|
||||
"-limit", "memory", memoryLimit,
|
||||
@@ -2594,6 +2725,7 @@ var commands = map[string]struct {
|
||||
"tag": {cmdTag, "GD SPACE [DESCRIPTION]", "Import tags."},
|
||||
"sync": {cmdSync, "GD ROOT...", "Synchronise with the filesystem."},
|
||||
"remove": {cmdRemove, "GD PATH...", "Remove database subtrees."},
|
||||
"forget": {cmdForget, "GD SHA1...", "Dispose of orphans."},
|
||||
"check": {cmdCheck, "GD", "Run consistency checks."},
|
||||
"thumbnail": {cmdThumbnail, "GD [SHA1...]", "Generate thumbnails."},
|
||||
"dhash": {cmdDhash, "GD [SHA1...]", "Compute perceptual hashes."},
|
||||
@@ -2657,6 +2789,9 @@ func main() {
|
||||
// Note that the database object has a closing finalizer,
|
||||
// we just additionally print any errors coming from there.
|
||||
if db != nil {
|
||||
if _, err := db.Exec(`PRAGMA optimize`); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ function call(method, params) {
|
||||
callActive++
|
||||
return m.request({
|
||||
method: "POST",
|
||||
url: `/api/${method}`,
|
||||
url: `api/${method}`,
|
||||
body: params,
|
||||
}).then(result => {
|
||||
callActive--
|
||||
@@ -98,7 +98,7 @@ let Thumbnail = {
|
||||
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,
|
||||
src: `thumb/${e.sha1}`, width: e.thumbW, height: e.thumbH,
|
||||
loading})
|
||||
},
|
||||
}
|
||||
@@ -472,13 +472,15 @@ let ViewBar = {
|
||||
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}))),
|
||||
]),
|
||||
Object.entries(ViewModel.tags).map(([space, tags]) =>
|
||||
m('details[open]', [
|
||||
m('summary', 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}))),
|
||||
])),
|
||||
])
|
||||
},
|
||||
}
|
||||
@@ -492,7 +494,7 @@ let View = {
|
||||
view(vnode) {
|
||||
const view = m('.view', [
|
||||
ViewModel.sha1 !== undefined
|
||||
? m('img', {src: `/image/${ViewModel.sha1}`,
|
||||
? m('img', {src: `image/${ViewModel.sha1}`,
|
||||
width: ViewModel.width, height: ViewModel.height})
|
||||
: "No image.",
|
||||
])
|
||||
@@ -609,13 +611,14 @@ let SearchRelated = {
|
||||
view(vnode) {
|
||||
return Object.entries(SearchModel.related)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([space, tags]) => [
|
||||
m('h2', space),
|
||||
.map(([space, tags]) => m('details[open]', [
|
||||
m('summary', m('h2',
|
||||
m(m.route.Link, {href: `/tags/${space}`}, space))),
|
||||
m('ul.tags', tags
|
||||
.sort((a, b) => (b.score - a.score))
|
||||
.map(({tag, score}) =>
|
||||
m(ScoredTag, {space, tagname: tag, score}))),
|
||||
])
|
||||
]))
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -24,13 +24,15 @@ a { color: inherit; }
|
||||
.header .activity { padding: .25rem .5rem; align-self: center; color: #fff; }
|
||||
.header .activity.error { color: #f00; }
|
||||
|
||||
summary h2, summary h3 { display: inline-block; }
|
||||
|
||||
.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 input { width: 100%; box-sizing: border-box; margin: .5rem 0;
|
||||
font-size: inherit; }
|
||||
.sidebar h2 { margin: 0.5em 0 0.25em 0; padding: 0; font-size: 1.2rem; }
|
||||
.sidebar ul { margin: .5rem 0; padding: 0; }
|
||||
.sidebar ul { margin: 0; padding: 0; }
|
||||
|
||||
.sidebar .path { margin: .5rem -.5rem; }
|
||||
.sidebar .path li { margin: 0; padding: 0; }
|
||||
@@ -81,7 +83,7 @@ img.thumbnail, .thumbnail.missing { box-shadow: 0 0 3px rgba(0, 0, 0, 0.75);
|
||||
.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 h3 { margin: 0.5em 0 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; }
|
||||
|
||||
7
test.sh
7
test.sh
@@ -16,6 +16,9 @@ sha1duplicate=$sha1
|
||||
cp $input/Test/dhash.png \
|
||||
$input/Test/multiple-paths.png
|
||||
|
||||
gen -seed 15 -size 256x256 plasma:fractal \
|
||||
$input/Test/excluded.png
|
||||
|
||||
gen -seed 20 -size 160x128 plasma:fractal \
|
||||
-bordercolor transparent -border 64 \
|
||||
$input/Test/transparent-wide.png
|
||||
@@ -36,7 +39,7 @@ gen $input/Test/animation-small.gif \
|
||||
$input/Test/video.mp4
|
||||
|
||||
./gallery init $target
|
||||
./gallery sync $target $input "$@"
|
||||
./gallery sync -exclude '/excluded[.]' $target $input "$@"
|
||||
./gallery thumbnail $target
|
||||
./gallery dhash $target
|
||||
./gallery tag $target test "Test space" <<-END
|
||||
@@ -47,7 +50,7 @@ END
|
||||
|
||||
# TODO: Test all the various possible sync transitions.
|
||||
mv $input/Test $input/Plasma
|
||||
./gallery sync $target $input
|
||||
./gallery sync -exclude '/excluded[.]' $target $input
|
||||
|
||||
./gallery web $target :8080 &
|
||||
web=$!
|
||||
|
||||
Reference in New Issue
Block a user