implemented sharing of bookmarks
This commit is contained in:
@@ -20,7 +20,7 @@ import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
|
|||||||
import de.srsoftware.umbrella.core.model.Token;
|
import de.srsoftware.umbrella.core.model.Token;
|
||||||
import de.srsoftware.umbrella.core.model.UmbrellaUser;
|
import de.srsoftware.umbrella.core.model.UmbrellaUser;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.ArrayList;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.json.JSONArray;
|
import org.json.JSONArray;
|
||||||
|
|
||||||
@@ -74,8 +74,8 @@ public class BookmarkApi extends BaseHandler {
|
|||||||
if (user.isEmpty()) return unauthorized(ex);
|
if (user.isEmpty()) return unauthorized(ex);
|
||||||
var head = path.pop();
|
var head = path.pop();
|
||||||
return switch (head) {
|
return switch (head) {
|
||||||
case SAVE -> postBookmark(user.get(),ex);
|
case null -> postBookmark(user.get(),ex);
|
||||||
case null, default -> super.doPost(path,ex);
|
default -> super.doPost(path,ex);
|
||||||
};
|
};
|
||||||
} catch (NumberFormatException e){
|
} catch (NumberFormatException e){
|
||||||
return sendContent(ex,HTTP_BAD_REQUEST,"Invalid project id");
|
return sendContent(ex,HTTP_BAD_REQUEST,"Invalid project id");
|
||||||
@@ -93,10 +93,19 @@ public class BookmarkApi extends BaseHandler {
|
|||||||
var json = json(ex);
|
var json = json(ex);
|
||||||
if (!(json.has(URL) && json.get(URL) instanceof String url)) throw missingFieldException(URL);
|
if (!(json.has(URL) && json.get(URL) instanceof String url)) throw missingFieldException(URL);
|
||||||
if (!(json.has(COMMENT) && json.get(COMMENT) instanceof String comment)) throw missingFieldException(COMMENT);
|
if (!(json.has(COMMENT) && json.get(COMMENT) instanceof String comment)) throw missingFieldException(COMMENT);
|
||||||
var bookmark = db.save(url,comment, user.id());
|
var userList = new ArrayList<Long>();
|
||||||
|
userList.add(user.id());
|
||||||
|
if (json.has(SHARE) && json.get(SHARE) instanceof JSONArray arr){
|
||||||
|
for (Object o : arr.toList()) {
|
||||||
|
if (!(o instanceof Number uid)) throw UmbrellaException.invalidFieldException(SHARE,"Array of ids");
|
||||||
|
userList.add(uid.longValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var bookmark = db.save(url,comment, userList);
|
||||||
|
|
||||||
if (json.has(TAGS) && json.get(TAGS) instanceof JSONArray tagList){
|
if (json.has(TAGS) && json.get(TAGS) instanceof JSONArray tagList){
|
||||||
var list = tagList.toList().stream().map(Object::toString).toList();
|
var list = tagList.toList().stream().map(Object::toString).toList();
|
||||||
tags.save(BOOKMARK,bookmark.id(), List.of(user.id()),list);
|
tags.save(BOOKMARK,bookmark.id(), userList, list);
|
||||||
}
|
}
|
||||||
return sendContent(ex,bookmark);
|
return sendContent(ex,bookmark);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package de.srsoftware.umbrella.bookmarks;
|
package de.srsoftware.umbrella.bookmarks;
|
||||||
|
|
||||||
import de.srsoftware.umbrella.core.model.Bookmark;
|
import de.srsoftware.umbrella.core.model.Bookmark;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
public interface BookmarkDb {
|
public interface BookmarkDb {
|
||||||
@@ -9,5 +10,5 @@ public interface BookmarkDb {
|
|||||||
|
|
||||||
Bookmark load(long id, long userId);
|
Bookmark load(long id, long userId);
|
||||||
|
|
||||||
Bookmark save(String url, String comment, long userId);
|
Bookmark save(String url, String comment, Collection<Long> userIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.srsoftware.umbrella.bookmarks;
|
|||||||
|
|
||||||
public class Constants {
|
public class Constants {
|
||||||
public static final String CONFIG_DATABASE = "umbrella.modules.bookmark.database";
|
public static final String CONFIG_DATABASE = "umbrella.modules.bookmark.database";
|
||||||
|
public static final String SHARE = "share";
|
||||||
public static final String SAVE = "save";
|
public static final String SAVE = "save";
|
||||||
public static final String TABLE_URLS = "urls";
|
public static final String TABLE_URLS = "urls";
|
||||||
public static final String TABLE_URL_COMMENTS = "url_comments";
|
public static final String TABLE_URL_COMMENTS = "url_comments";
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import de.srsoftware.umbrella.core.model.Bookmark;
|
|||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ CREATE TABLE IF NOT EXISTS {0} (
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Bookmark save(String url, String comment, long userId) {
|
public Bookmark save(String url, String comment, Collection<Long> userIds) {
|
||||||
try {
|
try {
|
||||||
var timestamp = LocalDateTime.now();
|
var timestamp = LocalDateTime.now();
|
||||||
var rs = select(ID).from(TABLE_URLS).where(URL, equal(url)).exec(db);
|
var rs = select(ID).from(TABLE_URLS).where(URL, equal(url)).exec(db);
|
||||||
@@ -112,9 +113,9 @@ CREATE TABLE IF NOT EXISTS {0} (
|
|||||||
rs.close();
|
rs.close();
|
||||||
stmt.close();
|
stmt.close();
|
||||||
}
|
}
|
||||||
replaceInto(TABLE_URL_COMMENTS,URL_ID,USER_ID,COMMENT, TIMESTAMP)
|
var query = replaceInto(TABLE_URL_COMMENTS,URL_ID,USER_ID,COMMENT, TIMESTAMP);
|
||||||
.values(id,userId,comment,timestamp.toEpochSecond(UTC))
|
for (long userId : userIds) query.values(id,userId,comment,timestamp.toEpochSecond(UTC));
|
||||||
.execute(db).close();
|
query.execute(db).close();
|
||||||
return Bookmark.of(id,url,comment,timestamp);
|
return Bookmark.of(id,url,comment,timestamp);
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
throw new UmbrellaException("Failed to store url");
|
throw new UmbrellaException("Failed to store url");
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
getCandidates = text => {},
|
getCandidates = async text => { conole.log('no handler for getCandidates('+text+')'); return {};},
|
||||||
onSelect = text => []
|
onSelect = text => []
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
let result = {};
|
let result = {};
|
||||||
result[key] = text;
|
result[key] = text;
|
||||||
options = {};
|
options = {};
|
||||||
|
text = '';
|
||||||
onSelect(result);
|
onSelect(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,10 +50,11 @@
|
|||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<select size={Object.keys(options).length<2?2:Object.keys(options).length+1} {onkeyup} {ondblclick} autofocus width="40">
|
{#if options}
|
||||||
|
<select size={Object.keys(options).length<2?2:Object.keys(options).length+1} {onkeyup} {ondblclick} width="40">
|
||||||
<option>{text}</option>
|
<option>{text}</option>
|
||||||
{#each Object.entries(options) as [val,caption]}
|
{#each Object.entries(options) as [val,caption]}
|
||||||
<option value={val}>{caption}</option>
|
<option value={val}>{caption}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
import PermissionSelector from './PermissionSelector.svelte';
|
import PermissionSelector from './PermissionSelector.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
members,
|
addMember = (entry) => console.log(`no handler for addMember(${entry})`),
|
||||||
getCandidates = text => {},
|
|
||||||
updatePermission = (uid,perm) => console.log(`no handler for updatePermission(${uid}, ${perm})`),
|
|
||||||
dropMember = (member) => console.log(`no handler for dropMember(${member})`),
|
dropMember = (member) => console.log(`no handler for dropMember(${member})`),
|
||||||
addMember = (entry) => console.log(`no handler for addMember(${entry})`)
|
getCandidates = text => {},
|
||||||
|
members,
|
||||||
|
updatePermission = (uid,perm) => console.log(`no handler for updatePermission(${uid}, ${perm})`)
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let error = $state(null);
|
let error = $state(null);
|
||||||
|
|||||||
50
frontend/src/Components/UserSelector.svelte
Normal file
50
frontend/src/Components/UserSelector.svelte
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script>
|
||||||
|
import { api } from '../urls.svelte.js';
|
||||||
|
import { t } from '../translations.svelte.js';
|
||||||
|
import { user } from '../user.svelte.js';
|
||||||
|
|
||||||
|
import Autocomplete from './Autocomplete.svelte';
|
||||||
|
|
||||||
|
let error = $state(null);
|
||||||
|
let {
|
||||||
|
getCandidates = async text => {},
|
||||||
|
heading = t('add_user'),
|
||||||
|
users = $bindable({})
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function dropUser(id){
|
||||||
|
delete users[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelect(entry){
|
||||||
|
for (let [k,v] of Object.entries(entry)){
|
||||||
|
users[k] = {name:v,id:k};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortedUsers = $derived.by(() => Object.values(users).sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<span class="error">{error}</span>
|
||||||
|
{/if}
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each sortedUsers as usr,idx}
|
||||||
|
<tr>
|
||||||
|
<td>{usr.name}</td>
|
||||||
|
<td>
|
||||||
|
{#if usr.id != user.id}
|
||||||
|
<button onclick={() => dropUser(usr.id)}>x</button>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
<tr>
|
||||||
|
<td>{heading}</td>
|
||||||
|
<td>
|
||||||
|
<Autocomplete {getCandidates} {onSelect} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import { t } from '../../translations.svelte.js';
|
import { t } from '../../translations.svelte.js';
|
||||||
|
|
||||||
import Editor from '../../Components/MarkdownEditor.svelte';
|
import Editor from '../../Components/MarkdownEditor.svelte';
|
||||||
|
import Users from '../../Components/UserSelector.svelte';
|
||||||
import Tags from '../tags/TagList.svelte';
|
import Tags from '../tags/TagList.svelte';
|
||||||
import Template from './Template.svelte';
|
import Template from './Template.svelte';
|
||||||
|
|
||||||
@@ -15,20 +16,36 @@
|
|||||||
rendered:null
|
rendered:null
|
||||||
},
|
},
|
||||||
tags:[],
|
tags:[],
|
||||||
url:null
|
url:null,
|
||||||
|
users:{}
|
||||||
});
|
});
|
||||||
let error = $state(null);
|
let error = $state(null);
|
||||||
|
|
||||||
|
async function getCandidates(text){
|
||||||
|
const url = api('user/search');
|
||||||
|
const resp = await fetch(url,{
|
||||||
|
credentials : 'include',
|
||||||
|
method : 'POST',
|
||||||
|
body : text
|
||||||
|
});
|
||||||
|
if (resp.ok){
|
||||||
|
error = null;
|
||||||
|
const input = await resp.json();
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(input).map(([key, value]) => [key, value.name])
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
error = await resp.text();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadBookmarks(){
|
async function loadBookmarks(){
|
||||||
const url = api('bookmark/list');
|
const url = api('bookmark/list');
|
||||||
const resp = await fetch(url,{credentials:'include'});
|
const resp = await fetch(url,{credentials:'include'});
|
||||||
if (resp.ok){
|
if (resp.ok){
|
||||||
const raw = await resp.json();
|
const raw = await resp.json();
|
||||||
bookmarks = Object.values(raw)
|
bookmarks = Object.values(raw).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||||
.sort(
|
|
||||||
(a, b) => new Date(b.timestamp) - new Date(a.timestamp)
|
|
||||||
);
|
|
||||||
|
|
||||||
error = false;
|
error = false;
|
||||||
} else {
|
} else {
|
||||||
error = await resp.html();
|
error = await resp.html();
|
||||||
@@ -37,12 +54,14 @@
|
|||||||
|
|
||||||
async function onclick(ev){
|
async function onclick(ev){
|
||||||
delete new_bookmark.comment.rendered;
|
delete new_bookmark.comment.rendered;
|
||||||
const url = api('bookmark/save');
|
const url = api('bookmark');
|
||||||
const data = {
|
const data = {
|
||||||
comment : new_bookmark.comment.source,
|
comment : new_bookmark.comment.source,
|
||||||
url : new_bookmark.url,
|
url : new_bookmark.url,
|
||||||
tags : new_bookmark.tags
|
tags : new_bookmark.tags
|
||||||
}
|
}
|
||||||
|
const share = Object.keys(new_bookmark.users).map(id => +id);
|
||||||
|
if (share.length>0) data.share = share;
|
||||||
const resp = await fetch(url,{
|
const resp = await fetch(url,{
|
||||||
credentials : 'include',
|
credentials : 'include',
|
||||||
method : 'POST',
|
method : 'POST',
|
||||||
@@ -66,12 +85,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<label>
|
<label>
|
||||||
{t('URL')}
|
{t('URL')}
|
||||||
<input bind:value={new_bookmark.url} />
|
<input bind:value={new_bookmark.url} autofocus />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t('Comment')}
|
{t('Comment')}
|
||||||
<Editor simple={true} bind:value={new_bookmark.comment} />
|
<Editor simple={true} bind:value={new_bookmark.comment} />
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
{t('share_with')}
|
||||||
|
<Users {getCandidates} users={new_bookmark.users} />
|
||||||
|
</label>
|
||||||
<Tags module="bookmark" bind:tags={new_bookmark.tags} />
|
<Tags module="bookmark" bind:tags={new_bookmark.tags} />
|
||||||
<button {onclick}>{t('save')}</button>
|
<button {onclick}>{t('save')}</button>
|
||||||
{#if bookmarks}
|
{#if bookmarks}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
|
|
||||||
<div class="taglist">
|
<div class="taglist">
|
||||||
<span class="tag editor">
|
<span class="tag editor">
|
||||||
<input type="text" bind:value={newTag} onkeyup={typed} autofocus />
|
<input type="text" bind:value={newTag} onkeyup={typed} />
|
||||||
</span>
|
</span>
|
||||||
{#each tags as tag,idx}
|
{#each tags as tag,idx}
|
||||||
<span class="tag">
|
<span class="tag">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"add_state": "Status hinzufügen",
|
"add_state": "Status hinzufügen",
|
||||||
"add_subtask": "Unteraufgabe hinzufügen",
|
"add_subtask": "Unteraufgabe hinzufügen",
|
||||||
"add_task": "Aufgabe hinzufügen",
|
"add_task": "Aufgabe hinzufügen",
|
||||||
|
"add_user": "Benutzer hinzufügen",
|
||||||
"advertisement" : "Umbrella ist ein Produkt von {producer}.",
|
"advertisement" : "Umbrella ist ein Produkt von {producer}.",
|
||||||
"allowed_states": "zulässige Status",
|
"allowed_states": "zulässige Status",
|
||||||
"amount": "Menge",
|
"amount": "Menge",
|
||||||
@@ -173,6 +174,7 @@
|
|||||||
"repeat_new_password": "Wiederholung",
|
"repeat_new_password": "Wiederholung",
|
||||||
"results": "Ergebnisse",
|
"results": "Ergebnisse",
|
||||||
|
|
||||||
|
"save": "speichern",
|
||||||
"saved": "gespeichert",
|
"saved": "gespeichert",
|
||||||
"save_note": "Notiz speichern",
|
"save_note": "Notiz speichern",
|
||||||
"save_service": "Service speichern",
|
"save_service": "Service speichern",
|
||||||
@@ -191,6 +193,7 @@
|
|||||||
"sent_email": "Email gesendet",
|
"sent_email": "Email gesendet",
|
||||||
"service": "Service",
|
"service": "Service",
|
||||||
"settings" : "Einstellungen",
|
"settings" : "Einstellungen",
|
||||||
|
"share_with": "Teilen mit:",
|
||||||
"show": "anzeigen",
|
"show": "anzeigen",
|
||||||
"show_closed": "geschlossene anzeigen",
|
"show_closed": "geschlossene anzeigen",
|
||||||
"show_kanban": "Kanban-Ansicht",
|
"show_kanban": "Kanban-Ansicht",
|
||||||
|
|||||||
Reference in New Issue
Block a user