Browse Source

Manage filters

isundil 1 năm trước cách đây
mục cha
commit
e4182cdc8f

+ 18 - 7
model/access.js

@@ -1,21 +1,23 @@
 
 const DatabaseModel = require("./DatabaseModel.js").DatabaseModel;
 
-const TYPE = {
+const ACCESS_TYPE = {
     unknown: 0,
     ldapAccount: 1,
     email: 2,
-    link: 3
+    link: 3,
+    everyOne: 4
 };
 
 const ACCESS_TO = {
     unknown: 0,
     item: 1,
     tag: 2,
-    meta: 3
+    meta: 3,
+    everything: 4
 };
 
-const GRANT = {
+const ACCESS_GRANT = {
     none: 0,
     read: 1,
     write: 2
@@ -24,11 +26,12 @@ const GRANT = {
 function AccessModel() {
     DatabaseModel.call(this);
     this.id = null;
-    this.type = TYPE.unknown;
+    this.type = ACCESS_TYPE.unknown;
     this.typeData = "";
     this.accessTo = ACCESS_TO.unknown;
     this.accessToData = "";
-    this.grant = GRANT.none;
+    this.accessToDataDeserialized = null;
+    this.grant = ACCESS_GRANT.none;
 }
 
 AccessModel.prototype = Object.create(DatabaseModel.prototype);
@@ -62,13 +65,21 @@ AccessModel.prototype.describe = function() {
 AccessModel.prototype.versionColumn = function() { return ""; }
 
 AccessModel.prototype.fromDb = function(dbObj) {
-    this.id = dbObj["sessionId"];
+    this.id = dbObj["id"];
     this.type = dbObj["type"];
     this.typeData = dbObj["typeData"];
     this.accessTo = dbObj["accessTo"];
     this.accessToData = dbObj["accessToData"];
+    try {
+        if (this.accessTo === ACCESS_TO.meta)
+            this.accessToDataDeserialized = JSON.parse(this.accessToData);
+    }
+    catch (err) {}
     this.grant = dbObj["grant"];
 }
 
 module.exports.AccessModel = AccessModel;
+module.exports.ACCESS_TYPE = ACCESS_TYPE;
+module.exports.ACCESS_TO = ACCESS_TO;
+module.exports.ACCESS_GRANT = ACCESS_GRANT;
 

+ 6 - 2
model/mediaItemTag.js

@@ -1,10 +1,11 @@
 
 const DatabaseModel = require("./DatabaseModel.js").DatabaseModel;
 
-function MediaFileTagModel(md5sum, tag) {
+function MediaFileTagModel(md5sum, tag, fromMeta) {
     DatabaseModel.call(this);
     this.md5sum = md5sum || "";
     this.tag = tag || "";
+    this.fromMeta = fromMeta;
 }
 
 MediaFileTagModel.prototype = Object.create(DatabaseModel.prototype);
@@ -17,13 +18,15 @@ MediaFileTagModel.prototype.createOrUpdateBase = async function(dbHelper) {
     await dbHelper.runSql(`CREATE TABLE IF NOT EXISTS 'mediaTag' (
         md5sum STRING NOT NULL,
         tag varchar(32) NOT NULL,
+        fromMeta BOOLEAN NOT NULL,
         PRIMARY KEY (md5sum, tag))`);
 }
 
 MediaFileTagModel.prototype.describe = function() {
     return {
         "md5sum": this.md5sum,
-        "tag": this.tag
+        "tag": this.tag,
+        "fromMeta": this.fromMeta
     };
 }
 
@@ -32,6 +35,7 @@ MediaFileTagModel.prototype.versionColumn = function() { return ""; }
 MediaFileTagModel.prototype.fromDb = function(dbObj) {
     this.md5sum = dbObj["md5sum"];
     this.tag = dbObj["tag"];
+    this.fromMeta = dbObj["fromMeta"];
 }
 
 module.exports.MediaFileTagModel = MediaFileTagModel;

+ 71 - 10
model/mediaService.js

@@ -1,6 +1,7 @@
 
 const path = require('path');
 const fs = require('fs');
+const { AccessModel, ACCESS_TYPE, ACCESS_TO, ACCESS_GRANT } = require('./access.js');
 
 function Media()
 {
@@ -13,6 +14,8 @@ function MediaStruct(i) {
     this.date = i.date;
     this.meta = {};
     this.tags = [];
+    this.fixedTags = [];
+    this.accessType = -1;
 }
 
 MediaStruct.prototype.pushMeta = function(key, value) {
@@ -31,25 +34,81 @@ MediaStruct.prototype.pushMeta = function(key, value) {
     }
 }
 
-MediaStruct.prototype.pushTag = function(tag) {
-    if (tag && this.tags.indexOf(tag) === -1)
+MediaStruct.prototype.pushTag = function(tag, isFixedTag) {
+    if (!tag)
+        return;
+    if (!isFixedTag && this.tags.indexOf(tag) === -1)
         this.tags.push(tag);
+    if (isFixedTag && this.fixedTags.indexOf(tag) === -1)
+        this.fixedTags.push(tag);
 }
 
-MediaStruct.prototype.HaveAccess = function(accessList) {
+MediaStruct.prototype.computeAccess = function(accessList) {
+    if (this.accessType > -1)
+        return this.accessType;
     if (!fs.existsSync(this.path))
+        return this.accessType = ACCESS_GRANT.none;
+    const checkTag = function(tags, access) {
+        if (!tags.length)
+            return false;
+        for (let i of tags)
+            if (i.startsWith(access.accessToData+'/') || i === access.accessToData)
+                return true;
         return false;
-    accessList = accessList || {};
-    for (let i in accessList) {
-        // FIXME
     }
-    return true;
+    const checkMeta = function(metas, access) {
+        if (!access.accessToDataDeserialized)
+            return false;
+        let metaKey = Object.keys(access.accessToDataDeserialized)[0];
+        let meta = metas[metaKey]?.value;
+        return meta && metaKey && meta == access.accessToDataDeserialized[metaKey];
+    }
+    this.accessType = ACCESS_GRANT.none;
+    for (let i of accessList) {
+        if (i.accessTo === ACCESS_TO.everything ||
+            (i.accessTo === ACCESS_TO.item && i.accessToData === this.md5sum) ||
+            (i.accessTo === ACCESS_TO.meta && checkMeta(this.meta, i)) ||
+            (i.accessTo === ACCESS_TO.tag && checkTag([].concat(this.fixedTags, this.tags), i))) {
+            if (i.grant === ACCESS_GRANT.write)
+                return this.accessType = ACCESS_GRANT.write;
+            this.accessType = ACCESS_GRANT.read;
+        }
+    }
+    return this.accessType;
+}
+
+MediaStruct.prototype.HaveAccess = function(accessList) {
+    return this.computeAccess(accessList) > 0;
+}
+
+async function buildAccessList(app, accessIds) {
+    accessIds = Object.keys(accessIds || {}).reduce((acc, i) => {
+        accessIds[i].linkId && acc.links.push(accessIds[i].linkId);
+        accessIds[i].ldapDn && acc.ldap.push(accessIds[i].ldapDn);
+        accessIds[i].email && acc.emails.push(accessIds[i].email);
+        return acc;
+    }, {links:[], emails: [], ldap: []});
+    accessIds.accData = [].concat(accessIds.ldap, accessIds.emails, accessIds.links);
+    accessIds.links = accessIds.links.map(x => '?').join(',');
+    accessIds.emails = accessIds.emails.map(x => '?').join(',');
+    accessIds.ldap = accessIds.ldap.map(x => '?').join(',');
+    let accessList = (await app.databaseHelper.runSql(`select * from access where (
+        (type=${ACCESS_TYPE.ldapAccount} AND typeData in (${accessIds.ldap})) OR
+        (type=${ACCESS_TYPE.email} AND typeData in (${accessIds.emails})) OR
+        (type=${ACCESS_TYPE.link} AND typeData in (${accessIds.links})) OR
+        type=${ACCESS_TYPE.everyOne}
+    )`, accessIds.accData)).map(data => {
+        let result = new AccessModel;
+        result.fromDb(data);
+        return result;
+    });
+    return accessList || [];
 }
 
 function reduceReqToMediaStruct(acc, i) {
     let obj = acc[i.md5sum] = acc[i.md5sum] || new MediaStruct(i);
     obj.pushMeta(i.metaKey, i.metaValue);
-    obj.pushTag(i.mediaTag);
+    obj.pushTag(i.mediaTag, i.isFixedTag);
     return acc;
 }
 
@@ -57,11 +116,12 @@ module.exports.fetchOne = async function(app, md5sum, accessList) {
     let result = ((await app.databaseHelper.runSql(`
         select mediaFile.path, mediaFile.md5sum, mediaFile.date,
         mediaMeta.key as metaKey, mediaMeta.value as metaValue,
-        mediaTag.tag as mediaTag
+        mediaTag.tag as mediaTag, mediaTag.fromMeta as isFixedTag
         from mediaFile
         left join mediaMeta on mediaMeta.md5sum=mediaFile.md5sum
         left join mediaTag on mediaTag.md5sum=mediaFile.md5sum
         where mediaFile.md5sum=?`, md5sum)) || []).reduce(reduceReqToMediaStruct, {})[md5sum] || null;
+    accessList = await buildAccessList(app, accessList);
     return result?.HaveAccess(accessList) ? result : null;
 }
 
@@ -69,7 +129,7 @@ module.exports.fetchMedias = async function(app, startTs, count) {
     let result = ((await app.databaseHelper.runSql(`
         select mediaFile.path, mediaFile.md5sum, mediaFile.date,
         mediaMeta.key as metaKey, mediaMeta.value as metaValue,
-        mediaTag.tag as mediaTag
+        mediaTag.tag as mediaTag, mediaTag.fromMeta as isFixedTag
         from mediaFile
         left join mediaMeta on mediaMeta.md5sum=mediaFile.md5sum
         left join mediaTag on mediaTag.md5sum=mediaFile.md5sum
@@ -87,6 +147,7 @@ module.exports.fetchMediasWithAccess = async function(app, startTs, count, acces
     let result = [];
     let lastTs = startTs;
 
+    access = await buildAccessList(app, access);
     while (result.length < count) {
         let tmp = await module.exports.fetchMedias(app, lastTs, 25);
         if (!tmp.length)

+ 2 - 0
package.json

@@ -13,7 +13,9 @@
   "author": "isundil",
   "license": "ISC",
   "dependencies": {
+    "@dashboardcode/bsmultiselect": "^1.1.18",
     "@mdi/font": "^7.3.67",
+    "@popperjs/core": "^2.11.8",
     "bootstrap": "^5.3.2",
     "bootstrap-icons": "^1.11.2",
     "craftlabhttpserver": "git:isundil/craftlabHttpServer",

+ 7 - 3
router/api.js

@@ -4,17 +4,21 @@ const fs = require('fs');
 const Path = require('path');
 const Security = require('../src/security.js');
 const MediaService = require('../model/mediaService.js');
+const { AccessModel, ACCESS_TYPE } = require('../model/access.js');
 
 module.exports = { register: app => {
     app.router.get("/api/access/list", (req, res) => {
         app.routerUtils.onApiRequest(req, res);
         app.routerUtils.jsonResponse(res, req.sessionObj?.accessList || {});
     });
-    app.router.post("/api/access/link", async (req, res) => { // /api/access/link, post: { linkId: string }
+    app.router.post("/api/access/link", async (req, res) => { // /api/access/link, post: { linkIds: [string] (JSON) }
         app.routerUtils.onApiRequest(req, res);
-        if (!req.post?.linkId?.length)
+        if (!req.post?.linkIds?.length)
             return app.routerUtils.httpResponse(res, 400, "Missing argument");
-        let access = Security.addLinkToSession(req, req.post.linkId);
+        for (let i of JSON.parse(req.post.linkIds)) {
+            if (await app.databaseHelper.findOne(AccessModel, { type: ACCESS_TYPE.link, typeData: i }))
+                Security.addLinkToSession(req, i);
+        }
         app.routerUtils.jsonResponse(res, req.sessionObj.accessList);
     });
     app.router.del("/api/access/:id", (req, res) => {

+ 3 - 0
router/mdi.js

@@ -9,6 +9,9 @@ module.exports = { register: app => {
     app.routerUtils.staticGet(app, "/public/fonts/materialdesignicons-webfont.ttf", './node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf');
     app.routerUtils.staticGet(app, "/public/fonts/materialdesignicons-webfont.woff", './node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff');
     app.routerUtils.staticGet(app, "/public/fonts/materialdesignicons-webfont.woff2", './node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2');
+    app.routerUtils.staticGet(app, "/public/js/BsMultiSelect.min.js", './node_modules/@dashboardcode/bsmultiselect/dist/js/BsMultiSelect.min.js');
+    app.routerUtils.staticGet(app, "/public/css/BsMultiSelect.min.js", './node_modules/@dashboardcode/bsmultiselect/dist/css/BsMultiSelect.min.css');
+    app.routerUtils.staticGet(app, "/public/js/popper.min.js", './node_modules/@popperjs/core/dist/umd/popper.min.js');
     app.routerUtils.staticGet(app, "/public/js/tasks.js", './static/public/js/tasks.js');
     app.routerUtils.staticGet(app, "/favicon.ico", './static/public/img/logo.svg');
 }};

+ 9 - 0
src/filetype/imagemagick.js

@@ -93,6 +93,14 @@ ExifGps.prototype.toGeoHash = function() {
     return geokit.hash({lat: this.data.lat, lng: this.data.lon});
 }
 
+function readTags(data) {
+    let result = [];
+    if (data['winXP-Keywords']) {
+        result = result.concat(Array.from(data["winXP-Keywords"]).filter((a, b) => !(b%2)).slice(0, -1).join("").split(";"));
+    }
+    return result;
+}
+
 module.exports.parse = async (fileObj) => {
     if (!fileObj.mimeType.startsWith('image/'))
         return {};
@@ -116,6 +124,7 @@ module.exports.parse = async (fileObj) => {
     const gpsData = new ExifGps(imdata);
     result.gpsLocation = gpsData.toGps();
     result.geoHash = gpsData.toGeoHash();
+    result.tags = readTags(imdata);
     for (let i of Object.keys(result))
         if (result[i] === undefined || result[i].length === 0)
             delete result[i];

+ 45 - 10
src/library.js

@@ -5,8 +5,10 @@ const md5Stats = require('craftlabhttpserver/src/md5sum').stats;
 const md5String = require('craftlabhttpserver/src/md5sum.js').string;
 const FileTypeManager = require('./fileTypeManager.js');
 
+const { ACCESS_TO, AccessModel } = require("../model/access.js");
 const MediaFileModel = require("../model/mediaItem.js").MediaFileModel;
 const MediaFileMetaModel = require("../model/mediaItemMeta.js").MediaFileMetaModel;
+const MediaFileTagModel = require("../model/mediaItemTag.js").MediaFileTagModel;
 const MANAGED_FILES = [ ".cr2" ]; // TODO
 const BUFFER_MAXSIZE = 100;
 
@@ -17,22 +19,47 @@ function File(fullPath, name) {
     this.mimeType = null;
     this.isMedia = null;
     this.meta = {};
+    this.dbItem = null;
+    this.tags = [];
+}
+
+File.prototype.getIsMedia = function() {
+    if (this.isMedia !== null)
+        return this.isMedia;
+    if (!this.mimeType)
+        this.mimeType = mime.lookup(this.name) || "";
+    this.isMedia = this.mimeType.startsWith("image/") || this.mimeType.startsWith("video/") || !!MANAGED_FILES.find(i => lowerName.endsWith(i));
+    return this.isMedia;
+}
+
+File.prototype.computeChecksum = async function() {
+    if (this.checksum)
+        return;
+    if (this.getIsMedia())
+        this.checksum = await md5Stats(this.path);
+    return this.checksum;
 }
 
 File.prototype.enrich = async function() {
     this.mimeType = mime.lookup(this.name) || "";
     const lowerName = this.name.toLowerCase();
-    this.isMedia = this.mimeType.startsWith("image/") || this.mimeType.startsWith("video/") || !!MANAGED_FILES.find(i => lowerName.endsWith(i));
-    this.checksum = "";
     this.meta = null;
-    if (this.isMedia)
-    {
+    await this.computeChecksum();
+    if (this.getIsMedia()) {
         this.meta = await FileTypeManager.createMeta(this);
-        this.checksum = await md5Stats(this.path);
+        this.tags = this.meta.tags || [];
+        this.meta.tags = undefined;
     }
 }
 
 File.prototype.saveDb = async function(db, libraryHash) {
+    if (this.dbItem) {
+        await db.remove(MediaFileModel, { path: this.dbItem.path, md5sum: this.dbItem.md5sum });
+        await db.remove(MediaFileMetaModel, { md5sum: this.dbItem.md5sum });
+        await db.remove(MediaFileTagModel, { md5sum: this.dbItem.md5sum, fromMeta: true });
+        await db.rawUpdate(MediaFileTagModel, { md5sum: this.dbItem.md5sum }, { md5sum: this.checksum });
+        await db.rawUpdate(AccessModel, { accessToData: this.dbItem.md5sum, accessTo: ACCESS_TO.item }, { accessToData: this.checksum });
+    }
     let entity = new MediaFileModel();
     let _this = this;
     entity.path = this.path;
@@ -41,8 +68,13 @@ File.prototype.saveDb = async function(db, libraryHash) {
     await db.insertOne(entity);
     this.meta.photochamberImport = new Date();
     this.meta.libraryPath = libraryHash;
-    let metaEntities = Object.keys(this.meta).map(i => new MediaFileMetaModel(_this.checksum, i, _this.meta[i]));
+    let metaEntities = Object.keys(this.meta).filter(i => i !== 'tags').map(i => new MediaFileMetaModel(_this.checksum, i, _this.meta[i]));
     await db.insertMultipleSameTable(metaEntities);
+    if (this.tags.length) {
+        let tagsEntities = this.tags.map(i => new MediaFileTagModel(_this.checksum, i, true));
+        await db.insertMultipleSameTable(tagsEntities);
+    }
+    this.dbItem = null;
 }
 
 File.prototype.createThumbnail = async function(w, h, quality) {
@@ -55,12 +87,13 @@ async function enrichAll(lib) {
 }
 
 async function Library_doUpdate(dbHelper, lib) {
+    lib.buff = lib.buff.filter(i => !!i.checksum);
     if (lib.buff.length === 0)
         return;
-    const dbItems = (await dbHelper.fetchRaw("path", MediaFileModel.prototype.getTableName.call(null), { "path": lib.buff.map(i => i.path) })).map(i => i.path);
-    lib.buff = lib.buff.filter(i => dbItems.indexOf(i.path) === -1);
+    const dbItems = (await dbHelper.fetchRaw(["path", "md5sum"], MediaFileModel.prototype.getTableName.call(null), { "path": lib.buff.map(i => i.path) }));
+    lib.buff.forEach(i => i.dbItem = dbItems.find(x => x.path == i.path));
+    lib.buff = lib.buff.filter(i => !i.dbItem || i.dbItem.md5sum != i.checksum);
     await enrichAll(lib);
-    lib.buff = lib.buff.filter(i => !!i.checksum);
     (await Promise.allSettled(lib.buff.map(i => i.saveDb(dbHelper, lib.dbHash)))).forEach(x => { if (x.status === 'rejected') { console.log(`Cannot update item: `, x.reason); }});
     lib.foundMedias = lib.foundMedias.concat(lib.buff);
     lib.buff = [];
@@ -72,7 +105,9 @@ async function Library_depthupdate(dbHelper, library, dir) {
         if (o.isDirectory())
             await Library_depthupdate(dbHelper, library, fullPath);
         else if (o.isFile()) {
-            library.buff.push(new File(fullPath, o.name));
+            let f = new File(fullPath, o.name);
+            await f.computeChecksum();
+            library.buff.push(f);
             if (library.buff.length >= BUFFER_MAXSIZE)
                 await Library_doUpdate(dbHelper, library);
         }

+ 13 - 0
static/public/css/style.css

@@ -133,6 +133,19 @@
     margin-right: .4em;
 }
 
+#pch-fullPageMedia .taglist {
+    margin: 0 -3px;
+}
+
+#pch-fullPageMedia .taglist li {
+    margin: 0 3px;
+}
+
+#pch-fullPageMedia .taglist .removeBt {
+    padding-left: calc(var(--bs-badge-padding-x) / 2);
+    margin-left: calc(var(--bs-badge-padding-x) / 2);
+}
+
 .login-wrapper {
     position: fixed;
     top: 0;

+ 19 - 6
static/public/js/common.js

@@ -1,11 +1,21 @@
 
 $(() => {
     function RebuildAccess() {
-        LoadingTasks.push(() => {
-            $.get("/api/access/list", data => {
-                window.ReloadAccessList(data);
+        let storedAccessLinks = JSON.parse(localStorage?.getItem("accessLinks") || "[]") || [];
+        if (storedAccessLinks && storedAccessLinks.length) {
+            LoadingTasks.push(() => {
+                return window.linkLogin(storedAccessLinks);
             });
-        });
+        } else {
+            LoadingTasks.push(() => {
+                return new Promise(ok => {
+                    $.get("/api/access/list", data => {
+                        window.ReloadAccessList(data);
+                        ok();
+                    });
+                });
+            });
+        }
     }
 
     let loadingMediaList = false;
@@ -17,15 +27,18 @@ $(() => {
                 let chronology = window.chronology.isInitialized() ? "" : "&chronology"
                 let oldest = (MediaStorage.Instance.oldest?.date?.getTime() || 0);
                 let oldestArg = oldest ? `&from=${oldest}` : "";
-                $.get(`/api/media/list?count=25${chronology}${oldestArg}`, data => {
+                $.get(`/api/media/list?count=75${chronology}${oldestArg}`, data => {
                     MediaStorage.Instance.pushAll(data.data.map(i => new Media(i)));
                     if (data.first || data.last)
                         window.chronology.rebuildRange(data.first, data.last);
                     loadingMediaList = false;
-                    if ((data.data?.length || 0) < 25) {
+                    if ((data.data?.length || 0) === 0) {
                         loadingMediaList = true;
                         document.getElementById("pch-infiniteScrollLoading").classList.add("hidden");
+                        window.ReloadFilters(MediaStorage.Instance);
                     }
+                    else
+                        setTimeout(ReadMediaList, 25);
                     ok();
                 });
             });

+ 52 - 0
static/public/js/filters.js

@@ -0,0 +1,52 @@
+
+window.FilterManager = (() => {
+    class FilterManager {
+        #updateFilter() {
+            for (let i of MediaStorage.Instance.medias) {
+                if (!i.ui)
+                    continue;
+                if (this.match(i))
+                    i.ui.root.classList.remove("filtered");
+                else
+                    i.ui.root.classList.add("filtered");
+            }
+            MediaStorage.Instance.onFilterUpdated();
+        }
+
+        setFilterValue(key, val) {
+            this.#filters[key] = val;
+            this.#updateFilter();
+        }
+
+        // Returns false if image doesnt match
+        #matchTags(mediaTags, tags) {
+            for (let i of tags)
+                if (mediaTags.find(x => x === i || x.startsWith(`${i}/`)))
+                    return true;
+            return false;
+        }
+
+        match(mediaItem) {
+            for (let i in this.#filters) {
+                if (!this.#filters[i] || !this.#filters[i].length)
+                    continue;
+                if (i === "Tags") {
+                    const mediaTags = mediaItem.allTags();
+                    if (this.#filters[i].indexOf(undefined) !== -1 && !mediaTags.length)
+                        continue;
+                    if (this.#matchTags(mediaTags, this.#filters[i].filter(i => !!i)))
+                        continue;
+                    return false;
+                }
+                if (this.#filters[i].indexOf(mediaItem.meta[i]?.value) === -1)
+                    return false;
+            }
+            return true;
+        }
+
+        #filters = {};
+    }
+
+    return new FilterManager();
+})();
+

+ 55 - 5
static/public/js/medias.js

@@ -6,11 +6,17 @@ class Media {
         this.path = data.path;
         this.fileName = data.fileName;
         this.meta = data.meta || {};
+        this.fixedTags = data.fixedTags || [];
         this.tags = data.tags || [];
+        this.writeAccess = data.accessType === 2;
         this.thumbnail = `/api/media/thumbnail/${data.md5sum}.jpg`;
         this.original = `/api/media/original/${data.md5sum}`;
         this.ui = null;
 
+
+        this.tags = this.tags.reduce((acc, tag) => { acc.add(tag.replaceAll(/\/\/+/gi, '/')); return acc; }, new Set());
+        this.fixedTags = this.fixedTags.reduce((acc, tag) => { acc.add(tag.replaceAll(/\/\/+/gi, '/')); return acc; }, new Set());
+
         for (let i in this.meta) {
             if (this.meta[i].type === 'date')
                 this.meta[i].value = new Date(parseInt(this.meta[i].value));
@@ -30,6 +36,10 @@ class Media {
             height: Math.floor(this.meta.height *ratio),
         };
     }
+
+    allTags() {
+        return Array.from(new Set(this.fixedTags, this.tags)).sort();
+    }
 }
 
 function tryLoadMedia(md5sum) {
@@ -49,24 +59,60 @@ class MediaStorage extends EventTarget
 {
     constructor() {
         super();
+        this.allMeta = {};
+        this.allMetaTypes = {};
+        this.allTags = new Set();
         this.medias = [];
         this.oldest = null;
         this.newest = null;
     }
 
+    #pushMeta(metaKey, metaVal) {
+        if (metaKey === 'dateTime')
+            return;
+        if (!this.allMeta[metaKey])
+            this.allMeta[metaKey] = new Set();
+        this.allMeta[metaKey].add(metaVal.value);
+        if (!this.allMetaTypes[metaKey])
+            this.allMetaTypes[metaKey] = { type: metaVal.type, canBeEmpty: !!this.medias.length };
+    }
+
+    #pushTag(tag, first) {
+        while (tag.length && tag.endsWith('/'))
+            tag = tag.substr(0, tag.length -1);
+        this.allTags.add(tag);
+        let index = tag.lastIndexOf('/');
+        if (index >= 0)
+            this.#pushTag(tag.substr(0, index));
+    }
+
+    #pushUnique(media) {
+        for (let i of media.tags)
+            this.#pushTag(i, true);
+        for (let i of media.fixedTags)
+            this.#pushTag(i, true);
+        for (let key in media.meta)
+            this.#pushMeta(key, media.meta[key]);
+        for (let key in this.allMetaTypes)
+            if (!media.meta[key])
+                this.allMetaTypes[key].canBeEmpty = true;
+        this.medias.push(media);
+        media.md5sum === 'b1bc7614d67333cacb60af149ed5ee1f' && console.log(this);
+    }
+
     pushAll(arr, partialLoad) {
         let result = [];
         let reorder = false;
         for (let i of arr) {
-            if (this.medias.find(x => x.md5sum === i.md5sum))
-                continue;
-            if (this.medias.length && this.medias[this.medias.length -1].date.getTime() < i.date.getTime())
-                reorder = true;
-            this.medias.push(i);
             if (partialLoad !== true) {
                 this.oldest = !this.oldest || this.oldest.date.getTime() > i.date.getTime() ? i : this.oldest;
                 this.newest = !this.newest || this.newest.date.getTime() < i.date.getTime() ? i : this.newest;
             }
+            if (this.medias.length && this.medias[this.medias.length -1].date.getTime() < i.date.getTime())
+                reorder = true;
+            if (this.medias.find(x => x.md5sum === i.md5sum))
+                continue;
+            this.#pushUnique(i);
             result.push(i);
         }
         for (let i of result)
@@ -77,6 +123,10 @@ class MediaStorage extends EventTarget
         }
     }
 
+    onFilterUpdated() {
+        this.dispatchEvent(new CustomEvent("rebuildMedia"));
+    }
+
     getMediaIndex(media) {
         return this.medias.indexOf(media);
     }

+ 27 - 16
static/public/js/uiAccess.js

@@ -24,29 +24,40 @@ loginUserPass.querySelector("button").addEventListener("click", () => {
     console.log([user, pass]);
 });
 
+window.linkLogin = function(linkList) {
+    return new Promise(ok => {
+        $.ajax({
+            url: "/api/access/link",
+            type: "POST",
+            data: { linkIds: JSON.stringify(linkList) },
+            success: data => {
+                window.ReloadAccessList(data);
+                ok(data);
+            },
+            error: err => ok(false),
+        });
+    });
+}
+
 loginCode.querySelector("button").addEventListener("click", () => {
     let code = loginCode.querySelector("input").value;
     if (!code)
         return;
-    LoadingTasks.push(() => {
-        return new Promise(ok => {
-            $.ajax({
-                url: "/api/access/link",
-                type: "POST",
-                data: { linkId: code },
-                success: data => {
-                    window.ReloadAccessList(data);
-                    closeLoginPopin();
-                    ok();
-                },
-                error: err => ok(false),
-            });
-        });
+    LoadingTasks.push(async () => {
+        data = await linkLogin([code]);
+        for (let i in data)
+            if (data[i].linkId === code) {
+                let storedAccessLinks = JSON.parse(localStorage?.getItem("accessLinks") || "[]") || [];
+                localStorage?.setItem("accessLinks", JSON.stringify([].concat(storedAccessLinks, code)));
+            }
+        closeLoginPopin();
     });
 });
 
-function logout(accessId) {
+function logout(accessId, linkId) {
     LoadingTasks.push(() => {
+        let storedAccessLinks = JSON.parse(localStorage?.getItem("accessLinks") || "[]") || [];
+        localStorage?.setItem("accessLinks", JSON.stringify(storedAccessLinks.filter(x => x !== linkId)));
         return new Promise(ok => {
             $.ajax({
                 url: `/api/access/${accessId}`,
@@ -106,7 +117,7 @@ window.ReloadAccessList = function(accessList) {
             e.preventDefault();
             if (!window.confirm("Logout account " +accessTextValue +" ?"))
                 return;
-            logout(i);
+            logout(i, accessList[i].linkId);
         });
     }
 }

+ 43 - 0
static/public/js/uiFilter.js

@@ -0,0 +1,43 @@
+
+$(() => {
+    window.ReloadFilters = function(mediaManager) {
+        let buildFilterBar = (labelText, canBeEmpty, possibleValues) => {
+            let result = document.createElement("div");
+            result.className = "col-12 col-xl-4 col-md-6"
+            let label = document.createElement('label');
+            result.appendChild(label);
+            label.textContent = labelText;
+            let select = document.createElement('select');
+            select.multiple = 'multiple';
+            label.appendChild(select);
+            for (let i of [].concat(canBeEmpty ? [undefined] : [], possibleValues)) {
+                let opt = document.createElement("option");
+                opt.textContent = i ? i : "(Empty)";
+                opt.value = i ?? "";
+                select.appendChild(opt);
+            }
+            $(select).change(e => {
+                let val = Array.from(select.children).filter(i => i.selected).map(i => i.value === '' ? undefined : i.value);
+                window.FilterManager.setFilterValue(labelText, val);
+            });
+            $(select).bsMultiSelect({cssPatch: {
+                picks: { backgroundColor: 'inherit' },
+                pick: { color: 'var(--bs-body-color)' },
+                choiceContent: { color: 'var(--bs-body-color)' }
+            }});
+            return {
+                content: result
+            };
+        };
+
+        let container = document.getElementById('pch-filterbar');
+        for (let i of Object.keys(mediaManager.allMetaTypes).filter(i => mediaManager.allMetaTypes[i].type === 'string')) {
+            let filterUi = buildFilterBar(i, mediaManager.allMetaTypes[i].canBeEmpty, Array.from(mediaManager.allMeta[i]).sort());
+            container.appendChild(filterUi.content);
+        }
+
+        let filterUi = buildFilterBar("Tags", true, Array.from(mediaManager.allTags).sort());
+        container.appendChild(filterUi.content);
+    }
+});
+

+ 40 - 7
static/public/js/uiMedia.js

@@ -70,7 +70,6 @@ $(() => {
                 lastKeyboardEvent = _lastKeyboardEvent;
             }
             lastSelection = mediaItem;
-            console.log(mediaItem);
         }
         container.addEventListener("click", () => {
             if (selectedThumbnails.length || lastKeyboardEvent?.ctrlKey) {
@@ -129,12 +128,14 @@ $(() => {
         newContainer.dataset.lastItemYear = null;
         newContainer.dataset.lastItemMonth = null;
         for (let i of MediaStorage.Instance.medias)
-            redraw(newContainer, i);
+            if (window.FilterManager.match(i))
+                redraw(newContainer, i);
     });
 
     MediaStorage.Instance.addEventListener("newMedia", (evt) => {
         let container = document.getElementById('pch-mediaList');
-        redraw(container, evt.detail);
+        if (window.FilterManager.match(evt.detail))
+            redraw(container, evt.detail);
     });
 
     function serializeFileSize(size) {
@@ -184,6 +185,8 @@ $(() => {
 
         metaData.libraryPath = null;
 
+        metaData.tags = metaData.fixedTags = null;
+
         if (metaData.exposureTime && metaData.exposureTimeStr)
             metaData.exposureTime = null;
 
@@ -209,7 +212,34 @@ $(() => {
         return metaList;
     }
 
-    function _displayMediaFullPage(fileName, imgUrl, metaData, downloadLink) {
+    function displayTags(fixedTagList, tagList, writeAccess) {
+        let tagListUi = document.createElement("ul");
+        tagListUi.className = "taglist";
+        let createTagUiItem = (i, roTag) => {
+            let uiItem = document.createElement("li");
+            uiItem.className = "badge text-bg-light";
+            let textItem = document.createElement("span");
+            textItem.textContent = i;
+            uiItem.appendChild(textItem);
+            if (!roTag) {
+                let btItem = document.createElement("a");
+                btItem.className = "border-start bi bi-x removeBt";
+                btItem.href = '#';
+                btItem.addEventListener('click', e => {
+                    e.preventDefault();
+                });
+                uiItem.appendChild(btItem);
+            }
+            tagListUi.appendChild(uiItem);
+        };
+        for (let i of fixedTagList)
+            createTagUiItem(i, true);
+        for (let i of tagList)
+            createTagUiItem(i, !writeAccess);
+        return tagListUi;
+    }
+
+    function _displayMediaFullPage(fileName, imgUrl, metaData, downloadLink, writeAccess) {
         return new Promise(ok => {
             document.getElementById("pch-fullPagePreviewContainer").classList.add("loading");
             document.getElementById("pch-fullPageMedia-title").innerText = fileName;
@@ -217,6 +247,7 @@ $(() => {
             document.getElementById("pch-fullPagePreview").src = imgUrl;
             document.getElementById("pch-fullPageDetail").innerText = "";
             document.getElementById("pch-fullPageDetail").appendChild(displayMetas(Object.create(metaData || {})));
+            document.getElementById("pch-fullPageDetail").appendChild(displayTags(metaData?.fixedTags || [], metaData?.tags || [], writeAccess));
             if (downloadLink) {
                 document.getElementById("pch-fullPageDetail-dlButton").classList.remove("hidden");
                 document.getElementById("pch-fullPageDetail-dlButton").dataset["link"] = downloadLink;
@@ -248,7 +279,7 @@ $(() => {
         document.getElementById("pch-fullPageMedia").classList.remove("hidden");
         fullPageMediaDisplayed = mediaItem;
         if (!mediaItem)
-            return _displayMediaFullPage("Error", null, {}, null);
+            return _displayMediaFullPage("Error", null, {}, [], null, false);
         let containerSize = document.getElementById("pch-fullPageMedia").getBoundingClientRect();
         let requestSize = mediaItem.resize(containerSize.width, containerSize.height);
         document.getElementById("pch-fullPagePreview").parentNode.style.maxWidth = "100%";
@@ -256,11 +287,13 @@ $(() => {
         let meta = {
             ...mediaItem.meta,
             date: { type: 'date', value: mediaItem.date } || undefined,
-            filename: mediaItem.filename ? { type: 'string', value: mediaItem.fileName } : undefined
+            filename: mediaItem.filename ? { type: 'string', value: mediaItem.fileName } : undefined,
+            fixedTags: mediaItem.fixedTags,
+            tags: mediaItem.tags
         };
         if (document.location.hash != `#${mediaItem.md5sum}`)
             history.pushState({}, '', `#${mediaItem.md5sum}`);
-        return _displayMediaFullPage(mediaItem.fileName, `${mediaItem.thumbnail}?w=${requestSize.width}&h=${requestSize.height}&q=6`, meta, mediaItem.original);
+        return _displayMediaFullPage(mediaItem.fileName, `${mediaItem.thumbnail}?w=${requestSize.width}&h=${requestSize.height}&q=6`, meta, mediaItem.original, mediaItem.writeAccess);
     }
 
     document.getElementById("pch-fullPageMedia-closeBt")

+ 4 - 0
templates/_footer.js

@@ -2,10 +2,14 @@
 module.exports = `
 <script src="/public/js/jquery-3.6.1.min.js"></script>
 <script src="/public/bootstrap/bootstrap.min.js"></script>
+<script src="/public/js/popper.min.js"></script>
+<script src="/public/js/BsMultiSelect.min.js"></script>
 <script src="/public/js/taskQueue.js"></script>
 <script src="/public/js/medias.js"></script>
+<script src="/public/js/filters.js"></script>
 <script src="/public/js/uiMedia.js"></script>
 <script src="/public/js/uiAccess.js"></script>
+<script src="/public/js/uiFilter.js"></script>
 <script src="/public/js/chronology.js"></script>
 <script src="/public/js/common.js"></script>
 </body></html>`;

+ 1 - 0
templates/_header.js

@@ -7,6 +7,7 @@ module.exports = `
 <link type="text/css" rel="stylesheet" href="/public/mdi/materialdesignicons.min.css"/>
 <link type="text/css" rel="stylesheet" href="/public/bootstrap/bootstrap.min.css"/>
 <link type="text/css" rel="stylesheet" href="/public/bootstrap/bootstrap-icons.min.css"/>
+<link type="text/css" rel="stylesheet" href="/public/css/BsMultiSelect.min.js"/>
 <link type="text/css" rel="stylesheet" href="/public/css/style.css"/>
 </head>
 `;

+ 1 - 6
templates/_menu.js

@@ -5,10 +5,6 @@ module.exports = `
     <nav id="pch-navbar" class="navbar navbar-expand-lg bg-body-tertiary">
         <div class="container-fluid" id="navbarSupportedContent">
             <ul class="navbar-nav col justify-content-end">
-                <form class="d-flex" role="search">
-                    <input class="form-control me-2" type="search" placeholder="Search" aria-label="Search">
-                    <button class="btn btn-outline-success" type="submit">Search</button>
-                </form>
                 <li class="nav-item dropdown">
                     <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
                         <i class="bi bi-person-circle">&nbsp;</i>
@@ -20,8 +16,7 @@ module.exports = `
             </ul>
         </div>
     </nav>
-    <nav id="pch-filterbar" class="navbar bg-body-tertiary"><div class="container-fluid">
-        Lorem Ipsum
+    <nav class="navbar bg-body-tertiary"><div class="container-fluid" id="pch-filterbar">
     </div></nav>
     </div>
 `;