Compare commits

..

18 Commits

Author SHA1 Message Date
StephanRichter 6aa858e299 improved range of states of tasks in easylist
Build Docker Image / Docker-Build (push) Successful in 3m47s
Build Docker Image / Clean-Registry (push) Successful in 6s
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-06-08 10:53:05 +02:00
StephanRichter 31545b8b11 css improvement
Build Docker Image / Clean-Registry (push) Failing after 10m34s
Build Docker Image / Docker-Build (push) Failing after 14m39s
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-05-24 20:21:48 +02:00
StephanRichter feafb44a9f Merge branch 'bugfix/autocomplete' into dev
Build Docker Image / Clean-Registry (push) Successful in 5s
Build Docker Image / Docker-Build (push) Failing after 14m59s
2026-05-22 08:46:39 +02:00
StephanRichter d187b6a2fc fixed typo
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-05-22 08:46:30 +02:00
StephanRichter 8d7de4b1b6 Merge branch 'bugfix/autocomplete' into dev
Build Docker Image / Clean-Registry (push) Failing after 11m24s
Build Docker Image / Docker-Build (push) Failing after 16m57s
2026-05-22 08:37:27 +02:00
StephanRichter e61e09d834 fixing dropdown disabling on dropdown click
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-05-22 08:37:22 +02:00
StephanRichter 3cff613335 Merge branch 'bugfix/autocomplete' into dev
Build Docker Image / Docker-Build (push) Successful in 3m13s
Build Docker Image / Clean-Registry (push) Has been cancelled
2026-05-22 08:26:25 +02:00
StephanRichter 0edeef2a9d implemented closing of autocomplete dropdown when leaving input
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-05-22 08:26:08 +02:00
StephanRichter 3a7779a665 Merge branch 'feature/easy-complete' into dev
Build Docker Image / Docker-Build (push) Successful in 2m52s
Build Docker Image / Clean-Registry (push) Successful in 6s
2026-05-19 16:06:22 +02:00
StephanRichter a1164e416a implemented saving of task/project from tag input field
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-05-19 16:05:43 +02:00
StephanRichter 2f8276c1be Merge branch 'bugfix/external-links' into dev
Build Docker Image / Docker-Build (push) Successful in 2m40s
Build Docker Image / Clean-Registry (push) Successful in 6s
2026-05-19 10:46:37 +02:00
StephanRichter 8b139b1bed moved markdown rendering from Util to MarkdownApi
Build Docker Image / Docker-Build (push) Successful in 2m44s
Build Docker Image / Clean-Registry (push) Successful in 6s
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-05-19 10:46:28 +02:00
StephanRichter 3b2371ad64 Merge branch 'bugfix/external-links' into dev
Build Docker Image / Docker-Build (push) Successful in 2m56s
Build Docker Image / Clean-Registry (push) Successful in 6s
2026-05-19 09:54:03 +02:00
StephanRichter aa48bbcbf5 implemented adding targets to links in markdown
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-05-19 09:53:43 +02:00
StephanRichter c5e31db99e Merge branch 'feature/parent_selector' into dev
Build Docker Image / Docker-Build (push) Successful in 4m25s
Build Docker Image / Clean-Registry (push) Successful in 1s
2026-05-14 00:32:23 +02:00
StephanRichter ccf8fc2089 implemented selector for parent task
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-05-14 00:32:10 +02:00
StephanRichter 098811547a Merge branch 'module/projects' into dev
Build Docker Image / Docker-Build (push) Successful in 5m20s
Build Docker Image / Clean-Registry (push) Successful in 3s
2026-05-11 20:14:32 +02:00
StephanRichter 9bec33d5de implemented editing of custom states
Build Docker Image / Docker-Build (push) Successful in 3m32s
Build Docker Image / Clean-Registry (push) Successful in 1s
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-05-11 20:14:24 +02:00
27 changed files with 398 additions and 216 deletions
@@ -63,8 +63,6 @@ public class Application {
var port = config.get("umbrella.http.port", 8080);
var threads = config.get("umbrella.threads", 16);
config.get("umbrella.plantuml").map(Object::toString).map(File::new).filter(File::exists).ifPresent(Util::setPlantUmlJar);
var server = HttpServer.create(new InetSocketAddress(port), 0);
try {
new Translations(config).bindPath("/api/translations").on(server);
@@ -80,7 +78,7 @@ public class Application {
new DocumentApi(config).bindPath("/api/document").on(server);
new UserLegacy(config).bindPath("/legacy/user").on(server);
new NotesLegacy(config).bindPath("/legacy/notes").on(server);
new MarkdownApi().bindPath("/api/markdown").on(server);
new MarkdownApi(config).bindPath("/api/markdown").on(server);
new NoteModule(config).bindPath("/api/notes").on(server);
new StockModule(config).bindPath("/api/stock").on(server);
new PollModule(config).bindPath("/api/poll").on(server);
@@ -1,7 +1,6 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.core;
import de.srsoftware.umbrella.core.api.*;
public class ModuleRegistry {
@@ -7,6 +7,7 @@ import static de.srsoftware.tools.PathHandler.GET;
import static de.srsoftware.tools.PathHandler.POST;
import static de.srsoftware.tools.Strings.hex;
import static de.srsoftware.umbrella.core.Errors.INVALID_URL;
import static de.srsoftware.umbrella.core.ModuleRegistry.markdownService;
import static de.srsoftware.umbrella.core.constants.Constants.TIME_FORMATTER;
import static de.srsoftware.umbrella.core.constants.Field.*;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.serverError;
@@ -35,14 +36,9 @@ import java.util.regex.Pattern;
import org.json.JSONObject;
public class Util {
public static final System.Logger LOG = System.getLogger("Util");
private static final Pattern UML_PATTERN = Pattern.compile("@start(\\w+)(.*?)@end(\\1)",Pattern.DOTALL);
private static final Pattern SPREADSHEET_PATTERN = Pattern.compile("@startsheet(.*?)@endsheet",Pattern.DOTALL);
private static File plantumlJar = null;
private static final JParsedown MARKDOWN = new JParsedown();
public static final String SHA1 = "SHA-1";
public static final System.Logger LOG = System.getLogger("Util");
public static final String SHA1 = "SHA-1";
private static final MessageDigest SHA1_DIGEST;
private static final Map<Integer,String> umlCache = new HashMap<>();
private static final String SCRIPT = """
<script src="http://127.0.0.1:8080/js/jspreadsheet-ce.js"></script>
@@ -111,71 +107,11 @@ jspreadsheet(document.getElementById('spreadsheet'), {
public static HashMap<String, Object> mapMarkdown(String source){
var map = new HashMap<String,Object>();
map.put(SOURCE,source);
map.put(RENDERED,markdown(source));
map.put(RENDERED,markdownService().markdown(source));
return map;
}
public static String markdown(String source){
if (source == null) return source;
try {
var matcher = SPREADSHEET_PATTERN.matcher(source);
var count = 0;
while (matcher.find()){
count++;
var sheetData = matcher.group(0).trim();
var start = matcher.start(0);
var end = matcher.end(0);
source = source.substring(0, start)
+ "<div class=\"spreadsheet\" id=\"spreadsheet-"+count+"\">"
+ sheetData.substring(11,sheetData.length()-10)
+ "</div>"
+ source.substring(end);
matcher = SPREADSHEET_PATTERN.matcher(source);
}
if (plantumlJar != null && plantumlJar.exists()) {
matcher = UML_PATTERN.matcher(source);
while (matcher.find()) {
var uml = matcher.group(0).trim();
var start = matcher.start(0);
var end = matcher.end(0);
var umlHash = uml.hashCode();
LOG.log(DEBUG,"Hash of Plantuml code: {0}",umlHash);
var svg = umlCache.get(umlHash);
if (svg != null){
LOG.log(DEBUG,"Serving Plantuml generated SVG from cache…");
source = source.substring(0, start) + svg + source.substring(end);
matcher = UML_PATTERN.matcher(source);
continue;
}
LOG.log(DEBUG,"Cache miss. Generating SVG from plantuml code…");
ProcessBuilder processBuilder = new ProcessBuilder("java", "-jar", plantumlJar.getAbsolutePath(), "-tsvg", "-pipe");
var ignored = processBuilder.redirectErrorStream();
var process = processBuilder.start();
try (OutputStream os = process.getOutputStream()) {
os.write(uml.getBytes(UTF_8));
os.flush();
}
try (InputStream is = process.getInputStream()) {
byte[] out = is.readAllBytes();
LOG.log(DEBUG,"Generated SVG. Pushing to cache…");
svg = new String(out, UTF_8);
umlCache.put(umlHash,svg);
source = source.substring(0, start) + svg + source.substring(end);
matcher = UML_PATTERN.matcher(source);
}
}
}
return MARKDOWN.text(source);
} catch (Throwable e){
if (LOG.isLoggable(TRACE)){
LOG.log(TRACE,"Failed to render markdown, input was: \n{0}",source,e);
} else LOG.log(WARNING,"Failed to render markdown. Enable TRACE log level for details.");
return source;
}
}
public static HttpURLConnection open(URL url) throws IOException {
var conn = (HttpURLConnection) url.openConnection();
@@ -249,11 +185,6 @@ jspreadsheet(document.getElementById('spreadsheet'), {
return new Hash(hex(bytes),SHA1);
}
public static void setPlantUmlJar(File file){
LOG.log(INFO,"Using plantuml @ {0}",file.getAbsolutePath());
plantumlJar = file;
}
public static String dateTimeOf(long epochMilis){
return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMilis), ZoneId.systemDefault()).format(TIME_FORMATTER);
}
@@ -2,4 +2,5 @@
package de.srsoftware.umbrella.core.api;
public interface MarkdownService {
String markdown(String source);
}
@@ -15,6 +15,7 @@ public class Field {
public static final String BODY = "body";
public static final String CACHE_CONTROL = "Cache-Control";
public static final String CHILDREN = "Children";
public static final String CODE = "code";
public static final String COMMENT = "comment";
public static final String COMPANY = "company";
@@ -33,13 +33,14 @@ public class Path {
public static final String OPTION = "option";
public static final String PAGE = "page";
public static final String PASSWORD = "password";
public static final String PERMISSIONS = "permissions";
public static final String PROJECT = "project";
public static final String PROPERTIES = "properties";
public static final String PROPERTY = "property";
public static final String PURPOSES = "purposes";
public static final String PAGE = "page";
public static final String PARENT_CANDIDATES = "parent_candidates";
public static final String PASSWORD = "password";
public static final String PERMISSIONS = "permissions";
public static final String PROJECT = "project";
public static final String PROPERTIES = "properties";
public static final String PROPERTY = "property";
public static final String PURPOSES = "purposes";
public static final String READ = "read";
public static final String REDIRECT = "redirect";
@@ -91,10 +91,7 @@ public class Poll implements Mappable {
return Map.of(
ID,id,
NAME,name,
DESCRIPTION, Map.of(
SOURCE,description,
RENDERED,Util.markdown(description)
),
DESCRIPTION, Util.mapMarkdown(description),
STATUS,status
);
}
@@ -292,10 +289,7 @@ public class Poll implements Mappable {
ID, id,
Field.OWNER, owner.toMap(),
NAME,name,
Field.DESCRIPTION, Map.of(
Field.SOURCE,description,
Field.RENDERED,Util.markdown(description)
),
Field.DESCRIPTION, Util.mapMarkdown(description),
Field.OPTIONS, options.stream().collect(Collectors.toMap(Option::id,Option::toMap)),
Field.PERMISSION, mapPermissions(),
Field.PRIVATE, isPrivate,
@@ -144,4 +144,9 @@ public class Project implements Mappable {
map.put(TAG_COLORS,tagColors);
return map;
}
@Override
public String toString() {
return name();
}
}
+1
View File
@@ -116,6 +116,7 @@
<Route path="/project/:project_id/add_task" component={AddTask} />
<Route path="/project/:id/kanban" component={Kanban} />
<Route path="/project/:id/view" component={ViewPrj} />
<Route path="/project/:id" component={ViewPrj} />
<Route path="/search" component={Search} />
<Route path="/stock" component={Stock} />
<Route path="/stock/location/:location_id" component={Stock} />
+17 -14
View File
@@ -49,12 +49,16 @@
console.warn(`${candidate.display} selected, but onSelect not overridden!`)
}
function disableDropdown(){
candidates = [];
selected = null;
}
async function ondblclick(evt){
const select = evt.target;
const idx = select.value;
candidate = candidates[idx];
candidates = [];
selected = null;
disableDropdown();
onSelect(candidate);
}
@@ -63,6 +67,10 @@
selected = null;
}
function onblur(ev){
setTimeout(disableDropdown,400);
}
async function onkeyup(ev){
if (ignore.includes(ev.key)) return;
if (ev.key == 'ArrowDown'){
@@ -83,22 +91,19 @@
ev.preventDefault();
if (selected != null && selected < candidates.length) {
candidate = candidates[selected];
candidates = [];
selected = null;
disableDropdown();
onSelect(candidate);
return false;
}
if (ev.key == 'Enter') {
candidates = [];
selected = null;
disableDropdown();
if (onCommit(candidate)) candidate = { display : '' };
}
return false;
}
if (ev.key == 'Escape'){
ev.preventDefault();
candidates = [];
selected = null;
disableDropdown();
return false;
}
@@ -110,10 +115,8 @@
function select(index){
candidate = candidates[index];
selected = null;
candidates = [];
disableDropdown();
onSelect(candidate);
}
function scrollTo(index){
@@ -145,11 +148,11 @@
</style>
<span class="autocomplete">
<input type="text" bind:value={candidate.display} {onkeyup} autofocus={autofocus} {id} />
<input type="text" bind:value={candidate.display} {onkeyup} autofocus={autofocus} {id} {onblur} />
{#if candidates && candidates.length > 0}
<ul bind:this={list_elem} class="suggestions">
<ul bind:this={list_elem} class="suggestions" tabindex="-1">
{#each candidates as candidate,i}
<li class="option {selected==i?'highlight':''}" onclick={e => select(i)} ondblclick={e => select(i)}>{candidate.display}</li>
<li class="option {selected==i?'highlight':''}" tabindex="-1" onclick={e => select(i)} ondblclick={e => select(i)}>{candidate.display}</li>
{/each}
</ul>
{/if}
+68 -74
View File
@@ -1,7 +1,7 @@
<script>
import { useTinyRouter } from 'svelte-tiny-router';
import { api } from '../../urls.svelte.js';
import { api, post } from '../../urls.svelte.js';
import { error, yikes } from '../../warn.svelte';
import { t } from '../../translations.svelte.js';
@@ -21,13 +21,9 @@
});
async function onsubmit(ev){
ev.preventDefault();
if (ev) ev.preventDefault();
const url = api('project');
var resp = await fetch(url,{
credentials : 'include',
method : 'POST',
body : JSON.stringify(project)
});
var resp = await post(url,project);
if (resp.ok){
var newProject = await resp.json();
router.navigate(`/project/${newProject.id}/view`);
@@ -50,72 +46,70 @@
label{ display: block }
</style>
<form {onsubmit}>
<fieldset>
<legend>
{t('create_new_project')}
</legend>
<fieldset>
<legend>
{t('create_new_project')}
</legend>
<fieldset>
<legend>{t('basic_data')}</legend>
<table>
<tbody>
<tr>
<th>
{t('company_optional')}
</th>
<td>
<CompanySelector caption={t('no_company')} {onselect} />
</td>
</tr>
<tr>
<th>
{t('Name')}
</th>
<td>
<input type="text" bind:value={project.name}/>
</td>
</tr>
<tr>
<th>
{t('description')}
</th>
<td>
<MarkdownEditor bind:value={project.description} simple={true} />
</td>
</tr>
{#if showSettings}
<tr>
<th>
{t('settings')}
</th>
<td>
<label>
<input type="checkbox" bind:checked={project.settings.show_closed} />
{t('display_closed_tasks')}
</label>
</td>
</tr>
{:else}
<tr>
<th>
{t('settings')}
</th>
<td>
<button onclick={toggleSettings} >{t('extended_settings')}</button>
</td>
</tr>
{/if}
<tr>
<th>
{t('tags')}
</th>
<td>
<Tags module="project" bind:tags={project.tags} />
</td>
</tr>
</tbody>
</table>
</fieldset>
<button type="submit" disabled={!ready}>{t('create')}</button>
<legend>{t('basic_data')}</legend>
<table>
<tbody>
<tr>
<th>
{t('company_optional')}
</th>
<td>
<CompanySelector caption={t('no_company')} {onselect} />
</td>
</tr>
<tr>
<th>
{t('Name')}
</th>
<td>
<input type="text" bind:value={project.name}/>
</td>
</tr>
<tr>
<th>
{t('description')}
</th>
<td>
<MarkdownEditor bind:value={project.description} simple={true} />
</td>
</tr>
{#if showSettings}
<tr>
<th>
{t('settings')}
</th>
<td>
<label>
<input type="checkbox" bind:checked={project.settings.show_closed} />
{t('display_closed_tasks')}
</label>
</td>
</tr>
{:else}
<tr>
<th>
{t('settings')}
</th>
<td>
<button onclick={toggleSettings} >{t('extended_settings')}</button>
</td>
</tr>
{/if}
<tr>
<th>
{t('tags')}
</th>
<td>
<Tags module={null} bind:tags={project.tags} onEmptyCommit={onsubmit} />
</td>
</tr>
</tbody>
</table>
</fieldset>
</form>
<button onclick={onsubmit} disabled={!ready}>{t('create')}</button>
</fieldset>
+20 -12
View File
@@ -2,7 +2,7 @@
import { onMount, onDestroy } from 'svelte';
import { useTinyRouter } from 'svelte-tiny-router';
import { api, eventStream } from '../../urls.svelte';
import { api, eventStream, patch, post } from '../../urls.svelte';
import { error, yikes } from '../../warn.svelte';
import { t } from '../../translations.svelte';
@@ -35,11 +35,7 @@
async function addState(){
const url = api(`project/${id}/state`);
const resp = await fetch(url,{
credentials: 'include',
method: 'POST',
body: JSON.stringify(new_state)
});
const resp = await post(url,new_state);
if (resp.ok){
const json = await resp.json();
project.allowed_states[json.code] = json.name;
@@ -139,11 +135,7 @@
async function update(data){
const url = api(`project/${id}`);
const resp = await fetch(url,{
credentials : 'include',
method : 'PATCH',
body : JSON.stringify(data)
});
const resp = await patch(url,data);
if (resp.ok){
yikes();
project = await resp.json();
@@ -160,6 +152,20 @@
update({members:members});
}
async function updateStateName(state_id,name){
const url = api(`project/${id}/state`);
const resp = await patch(url,{id:state_id,name});
if (resp.ok){
const json = await resp.json();
project.allowed_states[json.code]=json.name;
yikes();
return true;
} else {
error(resp);
return false;
}
}
function showClosed(){
show_closed = !show_closed;
loadTasks();
@@ -243,7 +249,9 @@
{/if}
{key}
</div>
<div>{project.allowed_states[key]}</div>
<div>
<LineEditor value={project.allowed_states[key]} editable={true} onSet={newName => updateStateName(+key,newName)} />
</div>
{/each}
<div>
<input type="number" bind:value={new_state.code} />
+2 -1
View File
@@ -79,6 +79,7 @@
task.members[assignee] = project.members[assignee];
task.members[assignee].permission = { name : "ASSIGNEE", code : 3 }
}
if (task.taks.length < 1) task.tags = project.tags;
yikes();
} else {
error(resp);
@@ -154,7 +155,7 @@
</div>
<div>{t('tags')}</div>
<div>
<Tags module="task" bind:tags={task.tags} />
<Tags module={null} bind:tags={task.tags} onEmptyCommit={saveTask} />
</div>
{#if extendedSettings}
+1 -1
View File
@@ -137,7 +137,7 @@
<legend>{t('state_open')}</legend>
{#if sorted}
{#each sorted as task}
{#if task.status == 20 && match(task)}
{#if task.status < 60 && task.status >= 20 && match(task)}
<div href={`/task/${task.id}/view`} title={task.description.source} task_id={task.id} {onclick} {oncontextmenu} {ontouchstart} {ontouchend} onmousedown={ontouchstart} onmouseup={ontouchend} >
{task.name}
</div>
@@ -0,0 +1,27 @@
<script>
import { onMount } from 'svelte';
import { t } from '../../translations.svelte';
import { api, get } from '../../urls.svelte';
import { error, yikes } from '../../warn.svelte';
import TaskTree from './Tree.svelte';
let { project, select = o => {}, task } = $props();
let tree = $state({});
async function loadParentCandidates(){
let url = api(`task/${task.id}/parent_candidates`);
let res = await get(url);
if (res.ok){
yikes();
tree = await res.json();
} else error(res);
}
onMount(loadParentCandidates);
</script>
<div class="overlay parent_selector">
<h2>{t('select a new parent for {entity}',{entity:task.name})}</h2>
{t('project')}: {project.name}
<TaskTree {tree} {select} />
</div>
+23
View File
@@ -0,0 +1,23 @@
<script>
let { select = o => {}, tree } = $props();
let nodes = $derived(Object.values(tree).sort((a,b) => a.name.localeCompare(b.name)))
function onclick(ev,node){
ev.preventDefault();
ev.stopPropagation();
select(node);
return true;
}
</script>
<ul>
{#each nodes as node (node.id)}
<li onclick={ev => onclick(ev,node)}>
{node.name}
{#if node.Children}
<svelte:self tree={node.Children} {select} />
{/if}
</li>
{/each}
</ul>
+28 -9
View File
@@ -10,6 +10,7 @@
import LineEditor from '../../Components/LineEditor.svelte';
import MarkdownEditor from '../../Components/MarkdownEditor.svelte';
import ParentSelector from './ParentSelector.svelte';
import PermissionEditor from '../../Components/PermissionEditor.svelte';
import Notes from '../notes/RelatedNotes.svelte';
import StateSelector from '../../Components/StateSelector.svelte';
@@ -22,6 +23,7 @@
let children = $state(null);
let dummy = $derived(updateOn(id));
let est_time = $state({sum:0});
let select_parent = $state(false);
let project = $state(null);
const router = useTinyRouter();
let showSettings = $state(router.fullPath.endsWith('/edit'));
@@ -74,12 +76,6 @@
router.navigate(`/project/${project.id}/kanban`)
}
function gotoParent(){
if (!task.parent_task_id) return;
router.navigate(`/task/${task.parent_task_id}/view`)
}
function gotoProject(){
if (!project) return;
router.navigate(`/project/${project.id}/view`)
@@ -136,6 +132,19 @@
} else error(await resp.text());
}
function parentClick(ev){
ev.preventDefault();
if (!task.parent_task_id) return;
router.navigate(`/task/${task.parent_task_id}/view`);
return false;
}
function parentRightClick(ev){
ev.preventDefault();
select_parent = true;
return false;
}
function showClosed(){
show_closed = !show_closed;
children = null;
@@ -182,6 +191,11 @@
loadTask();
}
function update_parent(newVal){
select_parent = false;
update({parent_task_id:newVal.id});
}
function updatePermission(user_id,permission){
let members = {};
members[user_id] = permission.code;
@@ -207,13 +221,18 @@
<button class="symbol" title={t('files')} onclick={showPrjFiles}></button>
</div>
{/if}
{#if task.parent}
<div>{t('parent_task')}</div>
<div class="parent">
<a href="#" onclick={gotoParent}>{task.parent.name}</a>
{#if select_parent}
<ParentSelector {task} {project} select={update_parent} />
{:else}
{#if task.parent}
<a href="/task/{task.parent.id}/view" onclick={parentClick} oncontextmenu={parentRightClick}>{task.parent.name}</a>
<button class="symbol" title={t('unlink')} onclick={unlink_parent}></button>
{/if}
<button class="symbol" title={t('edit')} onclick={parentRightClick}></button>
{/if}
</div>
{/if}
<div>{t('task')}</div>
<div class="name">
<LineEditor bind:value={task.name} editable={true} onSet={val => update({name:val})} />
@@ -3,9 +3,7 @@ package de.srsoftware.umbrella.legacy;
import static de.srsoftware.tools.Optionals.nullable;
import static de.srsoftware.umbrella.core.ModuleRegistry.noteService;
import static de.srsoftware.umbrella.core.ModuleRegistry.userService;
import static de.srsoftware.umbrella.core.Util.markdown;
import static de.srsoftware.umbrella.core.ModuleRegistry.*;
import static de.srsoftware.umbrella.core.constants.Field.TOKEN;
import static de.srsoftware.umbrella.core.constants.Field.URI;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.invalidField;
@@ -72,7 +70,7 @@ public class NotesLegacy extends BaseHandler {
new Tag("fieldset")
.add(new Tag("legend").content(authorName))
.add(new Tag("legend").content(note.timestamp().format(DateTimeFormatter.ISO_DATE_TIME)))
.add(new Tag("div").content(markdown(note.text())))
.add(new Tag("div").content(markdownService().markdown(note.text())))
.addTo(html);
}
return sendContent(ex,html.toString(2));
@@ -3,21 +3,53 @@ package de.srsoftware.umbrella.markdown;
import static de.srsoftware.tools.MimeType.MIME_HTML;
import static de.srsoftware.umbrella.core.ModuleRegistry.userService;
import static de.srsoftware.umbrella.core.constants.Field.BASE_URL;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.missingField;
import static java.lang.System.Logger.Level.*;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.sun.net.httpserver.HttpExchange;
import com.xrbpowered.jparsedown.JParsedown;
import de.srsoftware.configuration.Configuration;
import de.srsoftware.tools.Path;
import de.srsoftware.umbrella.core.BaseHandler;
import de.srsoftware.umbrella.core.ModuleRegistry;
import de.srsoftware.umbrella.core.Util;
import de.srsoftware.umbrella.core.api.MarkdownService;
import de.srsoftware.umbrella.core.constants.Field;
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;
public class MarkdownApi extends BaseHandler implements MarkdownService {
private static final System.Logger LOG = System.getLogger(MarkdownApi.class.getSimpleName());
private static final Pattern PATTERN_ANCHOR = Pattern.compile("(?is)<a\\b[^>]*>.*?</a>");
private static final Pattern PATTERN_HREF = Pattern.compile("(?i)\\bhref\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s>]+))");
private static final Pattern PATTERN_SPREADSHEET = Pattern.compile("@startsheet(.*?)@endsheet",Pattern.DOTALL);
private static final Pattern PATTERN_TARGET = Pattern.compile("(?i)\\btarget\\s*=");
private static final Pattern PATTERN_URL = Pattern.compile("@start(\\w+)(.*?)@end(\\1)",Pattern.DOTALL);
private static final Map<Integer,String> umlCache = new HashMap<>();
private static final JParsedown MARKDOWN = new JParsedown();
public MarkdownApi() {
private final String baseUrl;
private final File plantumlJar;
public MarkdownApi(Configuration config) {
super();
Optional<String> baseUrl = config.get(BASE_URL);
if (baseUrl.isEmpty()) throw missingField(BASE_URL);
this.baseUrl = baseUrl.get();
plantumlJar = config.get("umbrella.plantuml").map(Object::toString).map(File::new).filter(File::exists).orElse(null);
if (plantumlJar != null) LOG.log(INFO,"Using plant uml @ {}",plantumlJar);
ModuleRegistry.add(this);
}
@@ -28,11 +60,98 @@ public class MarkdownApi extends BaseHandler implements MarkdownService {
var user = userService().refreshSession(ex);
if (user.isEmpty()) throw UmbrellaException.forbidden("You must be logged in to use the markdown renderer!");
var rendered = Util.markdown(body(ex));
var rendered = markdown(body(ex));
ex.getResponseHeaders().add(CONTENT_TYPE,MIME_HTML);
return sendContent(ex,rendered);
} catch (UmbrellaException e){
return send(ex,e);
}
}
public String markdown(String source){
if (source == null || source.isBlank()) return source;
try {
var matcher = PATTERN_SPREADSHEET.matcher(source);
var count = 0;
while (matcher.find()){
LOG.log(DEBUG,"Processing spreadsheet code…");
count++;
var sheetData = matcher.group(0).trim();
var start = matcher.start(0);
var end = matcher.end(0);
source = source.substring(0, start)
+ "<div class=\"spreadsheet\" id=\"spreadsheet-"+count+"\">"
+ sheetData.substring(11,sheetData.length()-10)
+ "</div>"
+ source.substring(end);
LOG.log(DEBUG,"Updated markdown with spreadsheet div.");
matcher.reset(source);
}
if (plantumlJar != null && plantumlJar.exists()) {
matcher = PATTERN_URL.matcher(source);
while (matcher.find()) {
var uml = matcher.group(0).trim();
var start = matcher.start(0);
var end = matcher.end(0);
var umlHash = uml.hashCode();
LOG.log(DEBUG,"Hash of Plantuml code: {0}",umlHash);
var svg = umlCache.get(umlHash);
if (svg != null){
LOG.log(DEBUG,"Serving Plantuml generated SVG from cache…");
source = source.substring(0, start) + svg + source.substring(end);
matcher.reset(source);
continue;
}
LOG.log(DEBUG,"Cache miss. Generating SVG from plantuml code…");
ProcessBuilder processBuilder = new ProcessBuilder("java", "-jar", plantumlJar.getAbsolutePath(), "-tsvg", "-pipe");
var ignored = processBuilder.redirectErrorStream();
var process = processBuilder.start();
try (OutputStream os = process.getOutputStream()) {
os.write(uml.getBytes(UTF_8));
os.flush();
}
try (InputStream is = process.getInputStream()) {
byte[] out = is.readAllBytes();
LOG.log(DEBUG,"Generated SVG. Pushing to cache…");
svg = new String(out, UTF_8);
umlCache.put(umlHash,svg);
source = source.substring(0, start) + svg + source.substring(end);
matcher.reset(source);
}
}
}
var rendered = MARKDOWN.text(source);
if (baseUrl == null) return rendered;
var anchors = PATTERN_ANCHOR.matcher(rendered);
while (anchors.find()){
String anchor = anchors.group();
LOG.log(TRACE,"Processing anchor: {}",anchor);
var urls = PATTERN_HREF.matcher(anchor);
if (!urls.find()) continue; // no url nothing to do
var href = urls.group(1) != null ? urls.group(1) : (urls.group(2) != null ? urls.group(2) : urls.group(3));
LOG.log(TRACE," encountered href = {}",href);
if (!href.startsWith("http")) continue; // relative url? good!
LOG.log(TRACE," {} is not a relative url!",href);
var target = PATTERN_TARGET.matcher(anchor);
if (target.find()) continue; // target already set leave untouched
LOG.log(TRACE," anchor has no target!");
if (href.startsWith(baseUrl)) continue; // local url don`t touch
LOG.log(TRACE," {} is not an internal url, adding anchor…",href);
var replacement = "<a target=\"_blank\""+anchor.substring(2);
rendered = rendered.replace(anchor, replacement);
anchors.reset(rendered);
}
return rendered;
} catch (Throwable e){
if (LOG.isLoggable(TRACE)){
LOG.log(TRACE,"Failed to render markdown, input was: \n{0}",source,e);
} else LOG.log(WARNING,"Failed to render markdown. Enable TRACE log level for details.");
return source;
}
}
}
@@ -97,6 +97,7 @@ public class ProjectModule extends BaseHandler implements ProjectService {
head = path.pop();
yield switch (head){
case null -> patchProject(ex,projectId,user.get());
case Path.STATE -> patchProjectState(ex,projectId,user.get());
default -> super.doPatch(path,ex);
};
}
@@ -229,6 +230,19 @@ public class ProjectModule extends BaseHandler implements ProjectService {
return sendContent(ex,project.toMap());
}
private boolean patchProjectState(HttpExchange ex, long projectId, UmbrellaUser user) throws IOException {
var project = loadMembers(projectDb.load(projectId));
if (!project.hasMember(user)) throw notAmember(t(PROJECT_WITH_ID,ID,project.name()));
var json = json(ex);
if (!json.has(ID)) throw missingField(ID);
if (!json.has(NAME)) throw missingField(NAME);
if (!(json.get(ID) instanceof Number fieldId)) throw invalidField(ID,Text.NUMBER);
var newName = json.getString(NAME);
if (newName.isBlank()) throw invalidField(NAME, STRING);
var newState = new Status(newName,fieldId.intValue());
return sendContent(ex, projectDb.save(projectId,newState));
}
private boolean postNewState(HttpExchange ex, long projectId, UmbrellaUser user) throws IOException {
var project = loadMembers(load(projectId));
@@ -267,7 +267,7 @@ CREATE TABLE IF NOT EXISTS {0} (
@Override
public Status save(long projectId, Status newState) {
try {
insertInto(TABLE_CUSTOM_STATES, PROJECT_ID, Field.CODE, NAME).values(projectId,newState.code(),newState.name()).execute(db).close();
replaceInto(TABLE_CUSTOM_STATES, PROJECT_ID, Field.CODE, NAME).values(projectId,newState.code(),newState.name()).execute(db).close();
return newState;
} catch (SQLException e) {
throw databaseException(FAILED_TO_CREATE_STATE).causedBy(e);
@@ -2,6 +2,7 @@
package de.srsoftware.umbrella.task;
import static de.srsoftware.tools.Optionals.is0;
import static de.srsoftware.tools.jdbc.Condition.*;
import static de.srsoftware.tools.jdbc.Query.*;
import static de.srsoftware.tools.jdbc.Query.SelectQuery.ALL;
@@ -236,7 +237,7 @@ CREATE TABLE IF NOT EXISTS {0} (
public Map<Long, Task> listProjectTasks(Long projectId, Long parentTaskId, boolean noIndex) throws UmbrellaException {
try {
var query = select(ALL).from(TABLE_TASKS).where(PROJECT_ID,equal(projectId));
if (parentTaskId != 0) query.where(PARENT_TASK_ID,equal(parentTaskId));
if (!is0(parentTaskId)) query.where(PARENT_TASK_ID,equal(parentTaskId));
if (!noIndex) query.where(NO_INDEX,notIn(1));
var tasks = new HashMap<Long,Task>();
var rs = query.exec(db);
@@ -32,6 +32,7 @@ import de.srsoftware.tools.SessionToken;
import de.srsoftware.umbrella.core.BaseHandler;
import de.srsoftware.umbrella.core.ModuleRegistry;
import de.srsoftware.umbrella.core.api.*;
import de.srsoftware.umbrella.core.constants.Field;
import de.srsoftware.umbrella.core.constants.Text;
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
import de.srsoftware.umbrella.core.model.*;
@@ -110,8 +111,11 @@ public class TaskModule extends BaseHandler implements TaskService {
case null -> getUserTasks(user.get(), ex);
default -> {
var taskId = Long.parseLong(head);
head = path.pop();
yield head == null ? getTask(ex, taskId, user.get()) : super.doGet(path, ex);
yield switch (path.pop()){
case null -> getTask(ex,taskId,user.get());
case PARENT_CANDIDATES -> getParentCandidates(ex,taskId, user.get());
default -> super.doGet(path,ex);
};
}
};
} catch (UmbrellaException e) {
@@ -188,6 +192,33 @@ public class TaskModule extends BaseHandler implements TaskService {
return sendContent(ex, result);
}
private boolean getParentCandidates(HttpExchange ex, long taskId, UmbrellaUser user) throws IOException {
var task = taskDb.load(taskId);
var project = projectService().load(task.projectId());
var projectTasks = taskDb.listProjectTasks(project.id(),null, false);
var mapped = projectTasks.values().stream().collect(Collectors.toMap(Task::id,Task::toMap));
var roots = new HashMap<Long,Map<String,Object>>();
for (var map : mapped.values()){
if (!(map.get(ID) instanceof Long id)) continue;
if (id == taskId) continue;
if (map.get(PARENT_TASK_ID) instanceof Long parentId) {
var parent = mapped.get(parentId);
if (parent != null) {
var o = parent.get(Field.CHILDREN);
Map<Long,Object> children;
if (o == null) {
children = new HashMap<>();
parent.put(Field.CHILDREN,children);
} else children = (Map<Long, Object>) o;
children.put(id, map);
}
} else {
roots.put(id, map);
}
}
return sendContent(ex,roots);
}
private boolean getPermissionList(HttpExchange ex) throws IOException {
var map = new HashMap<Integer, String>();
for (var permission : Permission.values()) map.put(permission.code(),permission.name());
@@ -293,7 +324,7 @@ public class TaskModule extends BaseHandler implements TaskService {
Task parent = taskMap.get(task.parentTaskId());
var trunk = placeInTree(parent, taskTree, taskMap);
@SuppressWarnings("unchecked")
ArrayList<Object> children = (ArrayList<Object>) trunk.computeIfAbsent(CHILDREN, k -> new ArrayList<>());
ArrayList<Object> children = (ArrayList<Object>) trunk.computeIfAbsent(Field.CHILDREN, k -> new ArrayList<>());
children.add(mappedTask);
return mappedTask;
}
+1
View File
@@ -325,6 +325,7 @@
"save_object": "{object} speichern",
"search": "Suche",
"searching…": "suche…",
"select a new parent for {entity}": "Neue Über-Aufgabe für „{entity}“ wählen",
"select_company" : "Wählen Sie eine ihrer Firmen:",
"select_customer": "Kunde auswählen",
"select_property": "Eigenschaft auswählen",
+1
View File
@@ -325,6 +325,7 @@
"save_object": "save {object}",
"search": "search",
"searching…": "searhcing…",
"select a new parent for {entity}": "select a new parent for '{entity}'",
"select_company" : "select on of you companies:",
"select_customer": "select customer",
"select_property": "select property",
@@ -74,6 +74,7 @@ tr:hover .taglist .tag button {
color: black;
}
code,
.code{
color: chocolate;
}
@@ -336,4 +337,4 @@ tr:hover .taglist .tag button {
background: black;
color: orange;
}
}
}
+11 -1
View File
@@ -15,9 +15,10 @@ body {
background-position: 98% 70px;
background-attachment: fixed;
}
.code,
code {
font-size: 16px;
font-family: monospace;
}
fieldset {
@@ -761,3 +762,12 @@ fieldset.vcard{
white-space: nowrap;
display: inline flow-root;
}
.parent_selector > ul {
position: absolute;
top: 120px;
left: 0;
right: 0;
bottom: 0;
overflow: auto;
}