feature (wip): wip for tags

This commit is contained in:
Mickael Kerjean 2023-05-22 22:29:31 +10:00
parent c4e5da9169
commit dbf0878335
10 changed files with 297 additions and 71 deletions

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 129 129" style="fill:#6f6f6f;stroke:#6f6f6f;">
<path d="m 51.741297,117.22165 c -2.466177,0 -4.788482,-0.96594 -6.535367,-2.69223 L 13.885528,83.208986 c -1.726339,-1.746857 -2.692249,-4.069156 -2.692249,-6.535348 0,-2.466185 0.96591,-4.788492 2.692249,-6.535368 L 66.764417,17.259402 c 3.000527,-3.000523 8.446629,-5.261167 12.700782,-5.261167 h 27.703341 c 5.09681,0 9.24816,4.151389 9.24816,9.227587 v 27.703353 c 0,4.233618 -2.26065,9.700278 -5.26116,12.700812 L 58.276658,114.50887 c -1.746869,1.74684 -4.04863,2.71278 -6.535361,2.71278 z M 79.465199,18.163668 c -2.548374,0 -6.535359,1.644105 -8.32334,3.452635 L 18.262982,74.495181 c -0.575448,0.575452 -0.883718,1.356397 -0.883718,2.178457 0,0.8426 0.328824,1.603006 0.883718,2.178453 l 31.320416,31.320439 c 0.575435,0.5754 1.356403,0.88368 2.178452,0.88368 0.842611,0 1.603015,-0.32882 2.178449,-0.88368 L 106.8192,57.293642 c 1.80851,-1.808537 3.45264,-5.774997 3.45264,-8.343889 V 21.246394 c 0,-1.705772 -1.37695,-3.082726 -3.08273,-3.082726 z" style="stroke-width:2.2;" />
<circle cx="142.1252" cy="101.61908" r="28.324646" style="fill-opacity:0;stroke-width:20;" transform="matrix(0.38692388,0,0,0.38692388,30.309631,2.968202)" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -65,6 +65,8 @@ export const Icon = (props) => {
img = "/assets/icons/delete.svg";
} else if (props.name === "share") {
img = "/assets/icons/share.svg";
} else if (props.name === "tag") {
img = "/assets/icons/tag.svg";
} else if (props.name === "bucket") {
img = img_bucket;
} else if (props.name === "download_white") {

View file

@ -1,56 +1,61 @@
import { cache, currentShare, currentBackend } from "../helpers/";
class TagManager {
all(tagPath = "/", maxSize = -1) {
all(tagPath = "/") {
return cache.get(cache.FILE_TAG, [currentBackend(), currentShare()]).then((DB) => {
if (DB === null) {
return [];
}
if (tagPath == "/") {
const scoreFn = (acc, el) => (acc + el.replace(/[^\/]/g, "").length);
const tags = Object.keys(DB.tags).sort((a, b) => {
if (DB.tags[a].length === DB.tags[b].length) {
return DB.tags[a].reduce(scoreFn, 0) - DB.tags[b].reduce(scoreFn, 0);
}
return DB.tags[a].length < DB.tags[b].length ? 1 : -1;
});
if(tags.length === 0) {
return ["Bookmark"];
} else if(tags.length >= 5) {
return ["All"].concat(tags.slice(0, 5));
}
return tags;
const tags = this._tagPathStringToArray(tagPath);
if (tags.length === 0) {
return Object.keys(DB.tags);
}
return [
// "Bookmark", "wiki", "B", "C", "D", "E", "F"
];
// STEP1: build the graph of selected tags
// STEP2: build the node that connects to the initial graph
return Object.keys(DB.tags)
.map((tag) => {
if (tags.indexOf(tag) !== -1) { // ignore tag that are already selected
return { tag, scrore: 0 };
}
return {
tag,
score: DB.tags[tag].reduce((path, acc) => {
// TODO
return acc;
}, 0),
}
})
.filter((t) => t && t.score > 0)
.sort((a, b) => a.score > b.score)
.map((d) => d.tag);
});
}
files(tagPath) {
const tags = this._tagPathStringToArray(tagPath, false);
if (tags.length === 0) return Promise.resolve([]);
else if (tags.length > 1) return Promise.resolve([]); // TODO
let tags = this._tagPathStringToArray(tagPath);
return cache.get(cache.FILE_TAG, [currentBackend(), currentShare()]).then((DB) => {
if(!DB) return [];
switch(tags[0]) {
case "All":
return this.all()
.then((tags) => (tags.reduce((acc, el) => {
return DB.tags[el] ? acc.concat(DB.tags[el]) : acc;
}, [])));
default:
return Promise.resolve(DB.tags[tags[0]] || []);
if (!DB) return [];
else if (!DB.tags) return [];
else if (tags.length === 0) tags = Object.keys(DB.tags);
// push all the candidates in an array
let paths = (DB.tags[tags[0]] || []).map((t) => ({path: t, tag: tags[0]}));
for (let i=1; i<tags.length; i++) {
const tp = DB.tags[tags[i]];
if (!tp) continue;
paths = paths.concat(tp.map((t) => ({path: t, tag: tags[i]})));
}
// mark element of the array that shouldn't be here
return paths;
});
}
_tagPathStringToArray(tagPathString, removeFirst = true) {
return tagPathString
.split("/")
.filter((r) => r !== "" && (removeFirst ? r !== "All" : true));
_tagPathStringToArray(tagPathString) {
return tagPathString.split("/").filter((r) => r !== "");
}
addTagToFile(tag, path) {
@ -72,13 +77,15 @@ class TagManager {
DB.tags[tag].splice(idx, 1);
if (DB.tags[tag].length === 0) {
delete DB.tags[tag];
delete DB.weight[tag];
}
return DB;
});
}
import(DB) {
if(JSON.stringify(Object.keys(DB)) !== JSON.stringify(["tags", "share", "backend"])) {
return Promise.reject(new Error("Not Valid"));
}
return cache.upsert(cache.FILE_TAG, [currentBackend(), currentShare()], () => {
return DB;
});
@ -89,7 +96,7 @@ class TagManager {
return cache.get(cache.FILE_TAG, key)
.then((a) => {
if (a === null) {
return {tags: {}, weight: {}, share: key[1], backend: key[0]}
return {tags: {}, share: key[1], backend: key[0]}
}
return a;
});

View file

@ -42,6 +42,10 @@ export function FrequentlyAccess({ files, tags }) {
<NgIf cond={!!tags && tags.length > 0}>
<span className="caption">{t("Tag")}</span>
<div className="frequent_wrapper">
<Link to={"/tags/"}>
<Icon name={"directory"} />
<div>All</div>
</Link>
{
tags && tags.map((tag, index) => {
return (

View file

@ -0,0 +1,139 @@
import React, { useState, useRef, useEffect } from "react";
import {
Icon, Input,
} from "../../components/";
import { Tags } from "../../model/";
import { t } from "../../locales/";
import "./tag.scss";
export function TagComponent({ path }) {
const [DB, setDB] = useState(null);
const [input, setInput] = useState("");
useEffect(() => {
Tags.export().then((db) => {
setDB(db);
});
}, []);
const onFormSubmit = (e) => {
if (!DB) return;
else if(!DB.tags) return;
e.preventDefault();
const it = input.trim().toLowerCase();
if (it === "") return;
const newDB = {...DB};
newDB.tags = {};
newDB.tags[it] = [path];
Object.keys(DB.tags).forEach((tag) => {
newDB.tags[tag] = DB.tags[tag];
});
setDB(newDB);
setInput("");
Tags.import(newDB);
};
const onClickTag = (tagName) => {
if (!DB) return;
else if(!DB.tags) return;
console.log("CLICK ON ", tagName, "idx", DB.tags[tagName].indexOf(path));
const newDB = {...DB};
if (isTagActive(tagName)) {
newDB.tags[tagName].splice(DB.tags[tagName].indexOf(path), 1);
} else {
newDB.tags[tagName].push(path);
}
setDB(newDB);
Tags.import(newDB);
};
const onClickMoveUp = (tagName) => {
if (!DB) return;
else if(!DB.tags) return;
const newDB = {...DB};
const keys = Object.keys(DB.tags) || [];
const n = keys.indexOf(tagName);
if (n === 0) return;
newDB.tags = {};
for (let i=0; i<keys.length; i++) {
if (i === n-1) {
newDB.tags[keys[i+1]] = DB.tags[keys[i+1]];
} else if (i === n) {
newDB.tags[keys[i-1]] = DB.tags[keys[i-1]];
} else {
newDB.tags[keys[i]] = DB.tags[keys[i]];
}
}
setDB(newDB);
Tags.import(newDB);
};
const onClickMoveDown = (tagName) => {
if (!DB) return;
else if(!DB.tags) return;
const newDB = {...DB};
const keys = Object.keys(DB.tags) || [];
const n = keys.indexOf(tagName);
if (n === keys.length - 1) return;
newDB.tags = {};
for (let i=0; i<keys.length; i++) {
if (i === n) {
newDB.tags[keys[i+1]] = DB.tags[keys[i+1]];
} else if (i === n+1) {
newDB.tags[keys[i-1]] = DB.tags[keys[i-1]];
} else {
newDB.tags[keys[i]] = DB.tags[keys[i]];
}
}
setDB(newDB);
Tags.import(newDB);
};
const onClickRemove = (tagName) => {
const newDB = {...DB};
delete newDB.tags[tagName];
Tags.import(newDB);
setDB(newDB);
};
const isTagActive = (tagName) => {
if (!DB) return false;
else if(!DB.tags) return false;
return DB.tags[tagName].indexOf(path) !== -1;
}
const TAGS = DB && Object.keys(DB.tags);
return (
<div className="component_tag">
<form onSubmit={(e) => onFormSubmit(e)}>
<Input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t("Create a Tag")}
autoFocus />
</form>
<div className="scroll-y">
{
TAGS && TAGS.length > 0 ?
Object.keys(DB.tags).map((tag) => (
<div key={tag} className={"box no-select" + (isTagActive(tag) ? " active" : "")}>
<div onClick={() => onClickTag(tag)}>{ tag } <span className="count">{ (DB.tags[tag] || []).length }</span></div>
<Icon name="arrow_top" onClick={() => onClickMoveUp(tag)} />
<Icon name="arrow_bottom" onClick={() => onClickMoveDown(tag)} />
<Icon name="close" onClick={() => onClickRemove(tag)} />
</div>
)) : (
<div className={"box no-select"}>
<div onClick={() => onClickTag(t("Bookmark"))}>{ t("Bookmark") }</div>
</div>
)
}
</div>
</div>
);
}

View file

@ -0,0 +1,43 @@
.component_tag {
input {
font-size: 1.2em;
}
input::placeholder {
font-weight: 100;
}
.box {
display: flex;
background: var(--bg-color);
transition: background 0.1s ease;
&.active{ background: var(--primary); color: var(--color); }
padding: 5px 10px;
border-radius: 3px;
> div {
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
img {
width: 20px;
background: rgba(0,0,0,0.05);
border-radius: 50%;
margin-left: 5px;
&[alt="close"] {
width: 14px;
padding: 3px;
}
}
.count {
opacity: 0.5;
font-size: 0.9rem;
&:before { content: "["; }
&:after { content: "]"; }
}
}
.scroll-y {
overflow-y: auto !important;
max-height: 200px;
}
}

View file

@ -8,6 +8,7 @@ import { Card, NgIf, Icon, EventEmitter, img_placeholder, Input } from "../../co
import { pathBuilder, basename, filetype, prompt, alert, leftPad, getMimeType, debounce, memory } from "../../helpers/";
import { Files } from "../../model/";
import { ShareComponent } from "./share";
import { TagComponent } from "./tag";
import { t } from "../../locales/";
@ -227,6 +228,13 @@ class ExistingThingComponent extends React.Component {
this.setState({ delete_request: false });
}
onTagRequest() {
alert.now(
<TagComponent path={this.props.file.path} type={this.props.file.type} />,
() => {},
)
}
onShareRequest(filename) {
alert.now(
<ShareComponent path={this.props.file.path} type={this.props.file.type} />,
@ -321,6 +329,7 @@ class ExistingThingComponent extends React.Component {
onClickRename={this.onRenameRequest.bind(this)}
onClickDelete={this.onDeleteRequest.bind(this)}
onClickShare={this.onShareRequest.bind(this)}
onClickTag={this.onTagRequest.bind(this)}
is_renaming={this.state.is_renaming}
can_rename={this.props.metadata.can_rename !== false}
can_delete={this.props.metadata.can_delete !== false}
@ -371,6 +380,7 @@ class Filename extends React.Component {
e.preventDefault();
e.stopPropagation();
this.props.onRename(this.state.filename);
return false;
}
onCancel() {
@ -380,6 +390,7 @@ class Filename extends React.Component {
preventSelect(e) {
e.preventDefault();
e.stopPropagation();
}
render() {
@ -412,7 +423,7 @@ class Filename extends React.Component {
<NgIf cond={this.props.is_renaming === true} type="inline">
<form
onClick={this.preventSelect}
onSubmit={this.onRename.bind(this)}>
onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); this.onRename(e) }}>
<input
value={this.state.filename}
onChange={(e) => this.setState({ filename: e.target.value })}
@ -443,6 +454,11 @@ const ActionButton = (props) => {
props.onClickShare();
};
const onTag = (e) => {
e.preventDefault();
props.onClickTag();
}
return (
<div className="component_action">
<NgIf
@ -453,14 +469,25 @@ const ActionButton = (props) => {
onClick={onRename}
className="component_updater--icon" />
</NgIf>
<NgIf
type="inline"
cond={props.can_delete !== false}>
<Icon
name="delete"
onClick={onDelete}
className="component_updater--icon" />
</NgIf>
{
/canary/.test(location.search) ? (
<span type="inline">
<Icon
name="tag"
onClick={onTag}
className="component_updater--icon" />
</span>
) : (
<NgIf
type="inline"
cond={props.can_delete !== false}>
<Icon
name="delete"
onClick={onDelete}
className="component_updater--icon" />
</NgIf>
)
}
<NgIf
type="inline"
cond={props.can_share !== false}>

View file

@ -39,21 +39,11 @@ export function TagsPageComponent({ match }) {
}
const onClickRemoveFile = (file) => {
prompt.now(
t("Confirm by typing") + ": remove",
(answer) => {
if (answer !== "remove") {
return Promise.resolve();
}
Tags.removeTagFromFile(
path.split("/").filter((r) => !!r).slice(-1)[0],
file,
);
setRefresh(refresh + 1);
return Promise.resolve();
},
() => {},
Tags.removeTagFromFile(
file.tag,
file.path,
);
setRefresh(refresh + 1);
}
const onClickMoreDropdown = (what) => {
@ -74,15 +64,12 @@ export function TagsPageComponent({ match }) {
notify.send(t("Not Valid"), "error");
return;
}
if(JSON.stringify(Object.keys(jsonObject)) !== JSON.stringify(["tags", "weight", "share", "backend"])) {
notify.send(t("Not Valid"), "error");
return;
}
setLoading(true);
Tags.import(jsonObject).then(() => {
setLoading(false);
setRefresh(refresh + 1);
}).catch((err) => {
notify.send(t(err && err.message), "error");
setLoading(false);
});
};
@ -111,7 +98,11 @@ export function TagsPageComponent({ match }) {
<div className="component_container">
<h1>
{
path.split("/").filter((r) => r).map((tag, idx) => (
path === "/" ? (
<Link to="/">
<Icon name="arrow_left" />home
</Link>
) : path.split("/").filter((r) => r).map((tag, idx) => (
<React.Fragment key={idx}>#{tag} </React.Fragment>
))
}
@ -173,15 +164,16 @@ export function TagsPageComponent({ match }) {
{
files && files.map((file, idx) => (
<div className="component_thing view-list" key={idx}>
<Link to={(isAFolder(file) ? URL_FILES : URL_VIEWER) + file}>
<Link to={(isAFolder(file.path) ? URL_FILES : URL_VIEWER) + file.path}>
<Card>
<span className="component_action" style={{float: "right"}} onClick={(e) => { e.preventDefault(); onClickRemoveFile(file)}}>
<Icon name="close" />
</span>
<span><Icon name={filetype(file)} /></span>
<span><Icon name={filetype(file.path)} /></span>
<span className="component_filename">
<span className="file-details">
{basename(file)} <br/><i>{file}</i>
{basename(file.path)}<br/>
{path === "/" && (<i>#{file.tag}&nbsp;</i>)}<i>{file.path}</i>
</span>
</span>
</Card>

View file

@ -18,9 +18,15 @@
h1 {
font-weight: 100;
text-transform: uppercase;
margin: -7px 0 0 0;
margin: 0 0 0 0;
float: left;
line-height: 30px;
font-size: 1.7rem;
line-height: 1.7rem;
a .component_icon {
float: left;
width: 25px;
height: 25px;
}
}
}
& > .component_container {

View file

@ -161,8 +161,10 @@ func CanManageShare(fn func(*App, http.ResponseWriter, *http.Request)) func(ctx
fn(ctx, res, req)
return
}
Log.Debug("middleware::session::share 'permission denied - s.CanShare[%+v] s.Backend[%s]'", s.CanShare, s.Backend)
} else {
Log.Debug("middleware::session::share 'permission denied - s.CanShare[%+v] s.Backend[%s] GenerateID[%s]'", s.CanShare, s.Backend, id)
}
Log.Debug("middleware::session::share 'permission denied - s.CanShare[%+v] s.Backend[%s] GenerateID[%s]'", s.CanShare, s.Backend, id)
SendErrorResult(res, ErrPermissionDenied)
return
}