Compare commits

...

27 Commits

Author SHA1 Message Date
StephanRichter f43105d983 extended documentation
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-06-05 15:32:49 +02:00
StephanRichter fbe2380034 extended documentation
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-06-05 15:23:41 +02:00
StephanRichter 84bb908216 working on task description
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-06-05 08:57:48 +02:00
StephanRichter f80bccfa0c working on demodata
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-06-05 08:47:31 +02:00
StephanRichter 372e371a8f adding demodata
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-05-30 15:00:01 +02:00
StephanRichter 51b2a497cf working on demodata
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-05-30 15:00:01 +02:00
StephanRichter 81b6090d88 extended demo data
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-05-30 15:00:01 +02:00
StephanRichter 1570aefc77 extended demo data
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-05-30 15:00:01 +02:00
StephanRichter 55cad3c854 extending demo data
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-05-30 15:00:01 +02:00
StephanRichter 747b2c1d48 started creating demo data
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-05-30 15:00:01 +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
51 changed files with 470 additions and 220 deletions
@@ -14,7 +14,6 @@ import de.srsoftware.umbrella.bookmarks.BookmarkApi;
import de.srsoftware.umbrella.company.CompanyModule;
import de.srsoftware.umbrella.contact.ContactModule;
import de.srsoftware.umbrella.core.SettingsService;
import de.srsoftware.umbrella.core.Util;
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
import de.srsoftware.umbrella.documents.DocumentApi;
import de.srsoftware.umbrella.files.FileModule;
@@ -34,7 +33,6 @@ import de.srsoftware.umbrella.translations.Translations;
import de.srsoftware.umbrella.user.UserModule;
import de.srsoftware.umbrella.web.WebHandler;
import de.srsoftware.umbrella.wiki.WikiModule;
import java.io.File;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.util.concurrent.Executors;
@@ -63,8 +61,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 +76,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;
@@ -14,7 +15,6 @@ import static java.lang.System.Logger.Level.*;
import static java.lang.System.Logger.Level.WARNING;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.xrbpowered.jparsedown.JParsedown;
import de.srsoftware.tools.Mappable;
import de.srsoftware.tools.Query;
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
@@ -31,18 +31,12 @@ import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.Map;
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 +105,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 +183,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
@@ -0,0 +1 @@
*.db-journal
Binary file not shown.
Binary file not shown.
Binary file not shown.
+74
View File
@@ -0,0 +1,74 @@
{
"umbrella": {
"base_url": "http://127.0.0.1:5173",
"logging": {
"rootLevel": "INFO"
},
"http": {
"port": 8080
},
"threads": 16,
"modules": {
"accounting": {
"database": "demodata/accounting.db"
},
"bookmark": {
"database": "demodata/bookmark.db"
},
"company": {
"database": "demodata/company.db"
},
"contact": {
"database": "demodata/contacts.db"
},
"document": {
"database": "demodata/documents.db",
"templates": "demodata/templates"
},
"files": {
"database": "demodata/files.db",
"base_dir": "demodata/filestore"
},
"journal": {
"database": "demodata/journal.db"
},
"message": {
"database": "demodata/message.db",
"smtp": {
"from": "umbrella@example.com",
"host": "none",
"pass": "none",
"port": 587,
"user": "none"
}
},
"notes": {
"database": "demodata/notes.db"
},
"poll": {
"database": "demodata/poll.db"
},
"project": {
"database": "demodata/projects.db"
},
"stock": {
"database": "demodata/stock.db"
},
"tags": {
"database": "demodata/tags.db"
},
"task": {
"database": "demodata/tasks.db"
},
"time": {
"database": "demodata/times.db"
},
"user": {
"database": "demodata/users.db"
},
"wiki": {
"database": "demodata/wiki.db"
}
}
}
}
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+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></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}
@@ -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,50 @@ 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.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 +57,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;
}
+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;
}