Compare commits
57 Commits
a8e3122152
...
demodata
| Author | SHA1 | Date | |
|---|---|---|---|
| 38430feca0 | |||
| 73f74a0929 | |||
| 0a3f33c70d | |||
| e7c8039249 | |||
| cc0dd85814 | |||
| 33bff55811 | |||
| 182060134e | |||
| 64be02d863 | |||
| 70ea315fa7 | |||
| 889599dc40 | |||
| ac0b61dca0 | |||
| 1d7b47aaa0 | |||
| f49b44cd56 | |||
| 07492d34de | |||
| 60777feaa3 | |||
| 9e4158ad19 | |||
| bcc1182dea | |||
| b81d518a2b | |||
| 4a83bb6bee | |||
| e17fdbc619 | |||
| 0879b53a88 | |||
| 433ea6ddd3 | |||
| 6aa858e299 | |||
| 6249cdb7b9 | |||
| 31545b8b11 | |||
| feafb44a9f | |||
| d187b6a2fc | |||
| 8d7de4b1b6 | |||
| e61e09d834 | |||
| 3cff613335 | |||
| 0edeef2a9d | |||
| 3a7779a665 | |||
| a1164e416a | |||
| 2f8276c1be | |||
| 8b139b1bed | |||
| 3b2371ad64 | |||
| aa48bbcbf5 | |||
| c5e31db99e | |||
| ccf8fc2089 | |||
| 098811547a | |||
| 9bec33d5de | |||
| 6193b727bd | |||
| f35882c967 | |||
| 1d1520534c | |||
| fe57749d9c | |||
| 883b90faa7 | |||
| d3f9ca2c5d | |||
| fe18cb8dc2 | |||
| 20f47055f6 | |||
| c6caf7aacc | |||
| cc3a3a23a2 | |||
| cfd5362b1d | |||
| ca24ada1fd | |||
| a8a4ab0985 | |||
| 2adba956de | |||
| bc9df18307 | |||
| 3a2ab75b15 |
@@ -41,11 +41,6 @@ jobs:
|
||||
docker push ${{ secrets.REGISTRY_PATH }}/umbrella:${{ gitea.ref_name }}
|
||||
docker push ${{ secrets.REGISTRY_PATH }}/umbrella:$TAG
|
||||
|
||||
- name: Restart umbrella.srsoftware.de
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
run: |
|
||||
curl -X POST -H "Authorization: Bearer ${{ secrets.ELDORADO_MAKE_BEARER }}" -d umbrella_25_start https://make.eldorado.srsoftware.de/launch
|
||||
|
||||
Clean-Registry:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.srsoftware.umbrella.accounting;
|
||||
|
||||
import de.srsoftware.umbrella.core.model.Account;
|
||||
import de.srsoftware.umbrella.core.model.Transaction;
|
||||
import de.srsoftware.umbrella.core.model.UmbrellaUser;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -13,7 +14,9 @@ public interface AccountDb {
|
||||
|
||||
void dropTransactionTag(long transactionId, String tag);
|
||||
|
||||
Optional<Transaction> lastTransaction(long accountId, String source, String dest, double amount);
|
||||
Collection<UmbrellaUser> getMembers(long accountId);
|
||||
|
||||
Optional<Transaction> lastTransaction(long accountId, String source, String destination, Double amount);
|
||||
|
||||
Collection<Account> listAccounts(long userId);
|
||||
|
||||
|
||||
@@ -7,11 +7,9 @@ import static de.srsoftware.umbrella.core.ConnectionProvider.connect;
|
||||
import static de.srsoftware.umbrella.core.ModuleRegistry.tagService;
|
||||
import static de.srsoftware.umbrella.core.ModuleRegistry.userService;
|
||||
import static de.srsoftware.umbrella.core.constants.Path.*;
|
||||
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.invalidField;
|
||||
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.missingField;
|
||||
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.*;
|
||||
import static de.srsoftware.umbrella.messagebus.MessageBus.messageBus;
|
||||
import static de.srsoftware.umbrella.messagebus.events.Event.EventType.CREATE;
|
||||
import static java.lang.System.Logger.Level.WARNING;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import de.srsoftware.configuration.Configuration;
|
||||
@@ -55,6 +53,7 @@ public class AccountingModule extends BaseHandler implements AccountingService {
|
||||
case TRANSACTION -> {
|
||||
try {
|
||||
var transaction = accountDb.loadTransaction(Long.parseLong(path.pop()));
|
||||
if (!accountDb.getMembers(transaction.accountId()).contains(user.get())) throw forbidden("You are not allowed to access account {id}",Field.ID,transaction.accountId());
|
||||
yield dropTransaction(transaction, user.get(), path, ex);
|
||||
} catch (NumberFormatException ignored) {
|
||||
yield super.doDelete(path,ex);
|
||||
@@ -128,6 +127,7 @@ public class AccountingModule extends BaseHandler implements AccountingService {
|
||||
default -> {
|
||||
try {
|
||||
var accountId = Long.parseLong(head);
|
||||
if (!accountDb.getMembers(accountId).contains(user.get())) throw forbidden("You are not allowed to access account {id}",Field.ID,accountId);
|
||||
yield postToAccount(accountId,path,user.get(),ex);
|
||||
} catch (NumberFormatException ignored) {
|
||||
yield super.doPost(path,ex);
|
||||
@@ -156,7 +156,6 @@ public class AccountingModule extends BaseHandler implements AccountingService {
|
||||
}
|
||||
|
||||
private boolean dropTransactionTag(UmbrellaUser user, Transaction transaction, HttpExchange ex) throws IOException {
|
||||
LOG.log(WARNING,"Missing permission check in AccountModule.dropTransactionTag!");
|
||||
var json = json(ex);
|
||||
if (!json.has(Field.TAG)) throw missingField(Field.TAG);
|
||||
var tag = json.getString(Field.TAG);
|
||||
@@ -186,7 +185,7 @@ public class AccountingModule extends BaseHandler implements AccountingService {
|
||||
}
|
||||
|
||||
private boolean getAccount(UmbrellaUser user, long accountId, HttpExchange ex) throws IOException {
|
||||
LOG.log(WARNING,"Missing authorization check in AccountingModule.getAccount(…)!");
|
||||
if (!accountDb.getMembers(accountId).contains(user)) throw forbidden("You are not allowed to access account {id}",Field.ID,accountId);
|
||||
return sendContent(ex, loadAccount(accountId));
|
||||
}
|
||||
|
||||
@@ -225,7 +224,7 @@ public class AccountingModule extends BaseHandler implements AccountingService {
|
||||
|
||||
private boolean patchTransaction(UmbrellaUser user, long transactionId, HttpExchange ex) throws IOException {
|
||||
var transaction = accountDb.loadTransaction(transactionId);
|
||||
LOG.log(WARNING,"Missing permission check in patchTransaction(…)!");
|
||||
if (!accountDb.getMembers(transaction.accountId()).contains(user)) throw forbidden("You are not allowed to access account {id}",Field.ID,transaction.accountId());
|
||||
var oldData = transaction.toMap();
|
||||
var json = json(ex);
|
||||
if (json.has(Field.AMOUNT)) transaction.amount(json.getDouble(Field.AMOUNT));
|
||||
@@ -312,12 +311,11 @@ public class AccountingModule extends BaseHandler implements AccountingService {
|
||||
var source = src.get(src.has(Field.ID) ? Field.ID : Field.DISPLAY).toString();
|
||||
if (!json.has(Field.DESTINATION)) throw missingField(Field.DESTINATION);
|
||||
if (!(json.get(Field.DESTINATION) instanceof JSONObject dst)) throw invalidField(Field.SOURCE,JSON);
|
||||
var dest = dst.get(dst.has(Field.ID) ? Field.ID : Field.DISPLAY).toString();
|
||||
if (!json.has(Field.AMOUNT)) throw missingField(Field.AMOUNT);
|
||||
if (!(json.get(Field.AMOUNT) instanceof Number amt)) throw invalidField(Field.AMOUNT,Text.NUMBER);
|
||||
var amount = amt.doubleValue();
|
||||
String destination = dst.has(Field.ID) ? dst.get(Field.ID).toString() : dst.has(Field.DISPLAY) ? dst.get(Field.DISPLAY).toString() : null;
|
||||
Double amount = null;
|
||||
if (json.has(Field.AMOUNT) && json.get(Field.AMOUNT) instanceof Number amt) amount = amt.doubleValue();
|
||||
|
||||
var transaction = accountDb.lastTransaction(accountId, source, dest, amount);
|
||||
var transaction = accountDb.lastTransaction(accountId, source, destination, amount);
|
||||
return transaction.isPresent() ? sendContent(ex,transaction.get()) : notFound(ex);
|
||||
}
|
||||
|
||||
@@ -337,7 +335,6 @@ public class AccountingModule extends BaseHandler implements AccountingService {
|
||||
}
|
||||
|
||||
private boolean postSearchTags(long accountId, UmbrellaUser user, HttpExchange ex) throws IOException {
|
||||
LOG.log(WARNING,"Missing authorization check in AccountingModule.getAccount(…)!");
|
||||
var key = body(ex);
|
||||
if (!key.trim().startsWith("{")) { // search tags that contain value of body
|
||||
var tags = accountDb.searchTagsContaining(key, accountId);
|
||||
|
||||
@@ -7,6 +7,7 @@ import static de.srsoftware.tools.jdbc.Condition.*;
|
||||
import static de.srsoftware.tools.jdbc.Query.*;
|
||||
import static de.srsoftware.tools.jdbc.Query.SelectQuery.ALL;
|
||||
import static de.srsoftware.umbrella.accounting.Constants.*;
|
||||
import static de.srsoftware.umbrella.core.ModuleRegistry.userService;
|
||||
import static de.srsoftware.umbrella.core.constants.Field.*;
|
||||
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.*;
|
||||
import static de.srsoftware.umbrella.core.model.Translatable.t;
|
||||
@@ -19,6 +20,7 @@ import de.srsoftware.umbrella.core.constants.Field;
|
||||
import de.srsoftware.umbrella.core.constants.Text;
|
||||
import de.srsoftware.umbrella.core.model.Account;
|
||||
import de.srsoftware.umbrella.core.model.Transaction;
|
||||
import de.srsoftware.umbrella.core.model.UmbrellaUser;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.time.ZoneOffset;
|
||||
@@ -147,22 +149,63 @@ public class SqliteDb extends BaseDb implements AccountDb {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Transaction> lastTransaction(long accountId, String source, String dest, double amount) {
|
||||
public Collection<UmbrellaUser> getMembers(long accountId) {
|
||||
try {
|
||||
var rs = select(ALL).from(TABLE_TRANSACTIONS)
|
||||
.where(ACCOUNT,equal(accountId)).where(SOURCE,equal(source)).where(DESTINATION,equal(dest)).where(AMOUNT,equal(amount))
|
||||
.sort(ID+" DESC")
|
||||
.limit(1)
|
||||
.exec(db);
|
||||
var userIds = new HashSet<Long>();
|
||||
var rs = select("DISTINCT "+ SOURCE).from(TABLE_TRANSACTIONS).where(ACCOUNT,equal(accountId)).exec(db);
|
||||
while (rs.next()) try {
|
||||
userIds.add(Long.parseLong(rs.getString(1)));
|
||||
} catch (NumberFormatException ignored) {}
|
||||
rs.close();
|
||||
rs = select("DISTINCT "+ DESTINATION).from(TABLE_TRANSACTIONS).where(ACCOUNT,equal(accountId)).exec(db);
|
||||
while (rs.next()) try {
|
||||
userIds.add(Long.parseLong(rs.getString(1)));
|
||||
} catch (NumberFormatException ignored) {}
|
||||
rs.close();
|
||||
var us = userService();
|
||||
return userIds.stream().map(us::loadUser).toList();
|
||||
} catch (SQLException e) {
|
||||
throw failedToLoadMembers(Text.ACCOUNT);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Transaction> lastTransaction(long accountId, String source, String destination, Double amount) {
|
||||
try {
|
||||
var query = select(ALL).from(TABLE_TRANSACTIONS).where(ACCOUNT,equal(accountId));
|
||||
if (source != null) query = query.where(SOURCE,equal(source));
|
||||
if (destination != null) query = query.where(DESTINATION,equal(destination));
|
||||
if (amount != null) query = query.where(AMOUNT,equal(amount));
|
||||
var rs = query.sort(ID+" DESC").limit(1).exec(db);
|
||||
Transaction ta = null;
|
||||
if (rs.next()) ta = Transaction.of(rs);
|
||||
rs.close();
|
||||
|
||||
if (ta == null && amount != null) { // try to search by amount, ignore source and dest
|
||||
rs = select(ALL).from(TABLE_TRANSACTIONS).where(ACCOUNT, equal(accountId)).where(AMOUNT, equal(amount))
|
||||
.sort(ID + " DESC").limit(1).exec(db);
|
||||
if (rs.next()) ta = Transaction.of(rs);
|
||||
rs.close();
|
||||
}
|
||||
|
||||
if (ta == null && source != null && destination != null) { // try to search by amount, ignore source and dest
|
||||
rs = select(ALL).from(TABLE_TRANSACTIONS)
|
||||
.where(SOURCE,equal(source))
|
||||
.where(DESTINATION,equal(destination))
|
||||
.where(ACCOUNT, equal(accountId))
|
||||
.sort(ID + " DESC").limit(1).exec(db);
|
||||
if (rs.next()) ta = Transaction.of(rs);
|
||||
rs.close();
|
||||
}
|
||||
|
||||
|
||||
if (ta != null){
|
||||
var tags = ta.tags();
|
||||
rs = select(TAG).from(TABLE_TAGS_TRANSACTIONS).leftJoin(TAG_ID,TABLE_TAGS,ID).where(TRANSACTION_ID,equal(ta.id())).exec(db);
|
||||
while (rs.next()) tags.add(rs.getString(1));
|
||||
rs.close();
|
||||
}
|
||||
|
||||
return nullable(ta);
|
||||
} catch (SQLException e) {
|
||||
throw failedToSearchDb(t(Text.ACCOUNTING));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -53,10 +53,9 @@ public class TransactionEvent extends Event<Transaction> {
|
||||
|
||||
private Translatable describeDetail(){
|
||||
var tr = payload();
|
||||
var head = subject();
|
||||
|
||||
var message = "{head}:\n\n{source}: {source_name}\n{destination}: {dest_name}\n{amount}: {value}\n{purpose}: {purpose_val}\n\n{link}";
|
||||
return t(message,Field.HEAD, head, SOURCE,t(Text.SOURCE), "source_name",tr.source(), DESTINATION,t(Text.DESTINATION),"dest_name",tr.destination(), AMOUNT,t(Text.AMOUNT), VALUE,tr.amount(), PURPOSE,t(Text.PURPOSE),"purpose_val",tr.purpose(),"link",link());
|
||||
var message = "{source}: {source_name}\n{destination}: {dest_name}\n{amount}: {value}\n{purpose}: {purpose_val}\n\n{link}";
|
||||
return t(message,SOURCE,t(Text.SOURCE), "source_name",tr.source(), DESTINATION,t(Text.DESTINATION),"dest_name",tr.destination(), AMOUNT,t(Text.AMOUNT), VALUE,tr.amount(), PURPOSE,t(Text.PURPOSE),"purpose_val",tr.purpose(),"link",link());
|
||||
}
|
||||
|
||||
private Translatable link() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*.db-journal
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -90,7 +90,7 @@
|
||||
{#if messages.warning}
|
||||
<span class="warn">{@html messages.warning}</span>
|
||||
{/if}
|
||||
<Route path="/" component={User} />
|
||||
<Route path="/" component={ProjectList} />
|
||||
<Route path="/account/:id" component={Account} />
|
||||
<Route path="/accounting" component={Accounts} />
|
||||
<Route path="/accounting/new" component={NewAccount} />
|
||||
@@ -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} />
|
||||
|
||||
@@ -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){
|
||||
@@ -132,7 +135,7 @@
|
||||
color: orange;
|
||||
border: 1px solid orange;
|
||||
border-radius: 5px;
|
||||
z-index: 50;
|
||||
z-index: 65;
|
||||
list-style: none;
|
||||
padding: 4px;
|
||||
margin: 0;
|
||||
@@ -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}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { error, yikes } from '../../warn.svelte';
|
||||
import { t } from '../../translations.svelte';
|
||||
|
||||
import EntryForm from './add_entry.svelte';
|
||||
import EntryForm from './add_entry_new.svelte';
|
||||
import Transaction from './transaction.svelte';
|
||||
|
||||
let { id } = $props();
|
||||
@@ -163,5 +163,5 @@
|
||||
</table>
|
||||
</fieldset>
|
||||
|
||||
<EntryForm {account} {onSave} />
|
||||
<EntryForm {account} {onSave} {users} />
|
||||
{/if}
|
||||
@@ -0,0 +1,205 @@
|
||||
<script>
|
||||
import { useTinyRouter } from 'svelte-tiny-router';
|
||||
|
||||
import { t } from '../../translations.svelte';
|
||||
import { api, post } from '../../urls.svelte';
|
||||
import { error, yikes } from '../../warn.svelte';
|
||||
import { user } from '../../user.svelte';
|
||||
import Autocomplete from '../../Components/Autocomplete.svelte';
|
||||
import Tags from '../tags/TagList.svelte';
|
||||
|
||||
let defaultAccount = {
|
||||
id : 0,
|
||||
name : '',
|
||||
currency : ''
|
||||
};
|
||||
let { account = defaultAccount, new_account = false, onSave = () => {}, users } = $props();
|
||||
|
||||
let entry = $state({
|
||||
account,
|
||||
date : new Date().toISOString().substring(0, 10),
|
||||
source : {
|
||||
display: user.name,
|
||||
id: user.id
|
||||
},
|
||||
destination : {},
|
||||
amount : 0.0,
|
||||
purpose : {},
|
||||
tags : []
|
||||
});
|
||||
let router = useTinyRouter();
|
||||
|
||||
async function dst_selected(destination){
|
||||
destination = JSON.parse(JSON.stringify(destination));
|
||||
let source = JSON.parse(JSON.stringify(entry.source));
|
||||
const url = api(`accounting/${entry.account.id}/tags`)
|
||||
const res = await post(url,{source,destination});
|
||||
if (res.ok) {
|
||||
yikes();
|
||||
const json = await res.json();
|
||||
await proposePurpose();
|
||||
entry.tags = json;
|
||||
} else error(res);
|
||||
}
|
||||
|
||||
function focusOnEnter(ev,id){
|
||||
if (ev.key == 'Enter') {
|
||||
proposePurpose();
|
||||
document.getElementById(id).focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function getAccountTags(text){
|
||||
if (!text) return [];
|
||||
const url = api(`accounting/${entry.account.id}/tags`)
|
||||
return await getProposals(text,url);
|
||||
}
|
||||
|
||||
async function getDestinations(text){
|
||||
const url = api('accounting/destinations');
|
||||
return await getProposals(text,url);
|
||||
}
|
||||
|
||||
async function getProposals(text,url){
|
||||
const res = await post(url,text);
|
||||
if (res.ok){
|
||||
yikes();
|
||||
const input = await res.json();
|
||||
return Object.values(input).map(mapDisplay);
|
||||
} else {
|
||||
error(res);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function getPurposes(text) {
|
||||
const url = api('accounting/purposes');
|
||||
return await getProposals(text,url);
|
||||
}
|
||||
|
||||
async function getSources(text){
|
||||
const url = api('accounting/sources');
|
||||
return await getProposals(text,url);
|
||||
}
|
||||
|
||||
function gotoTags(purpose){
|
||||
document.getElementById('new_tag_input');
|
||||
}
|
||||
|
||||
function mapDisplay(object){
|
||||
if (object.display){
|
||||
return object;
|
||||
} else if (object.name) {
|
||||
return {...object, display: object.name};
|
||||
} else {
|
||||
return { display : object }
|
||||
}
|
||||
}
|
||||
|
||||
async function proposePurpose(){
|
||||
console.log('proposePurpose()');
|
||||
const amount = entry.amount;
|
||||
const source = entry.source;
|
||||
const destination = entry.destination;
|
||||
const url = api(`accounting/${account.id}/purposes`);
|
||||
const res = await post(url,{source,destination,amount});
|
||||
if (res.ok) {
|
||||
yikes();
|
||||
var lastTransaction = await res.json();
|
||||
console.log({lastTransaction,users:JSON.parse(JSON.stringify(users))});
|
||||
entry.purpose = { display: lastTransaction.purpose};
|
||||
entry.tags = lastTransaction.tags;
|
||||
if (lastTransaction.source.value){
|
||||
if (users[lastTransaction.source.value]){
|
||||
let user = users[lastTransaction.source.value];
|
||||
entry.source = { id : +lastTransaction.source.value, display : user.name };
|
||||
} else entry.source = { display: lastTransaction.source.value };
|
||||
}
|
||||
if (lastTransaction.destination.value){
|
||||
if (users[lastTransaction.destination.value]){
|
||||
let user = users[lastTransaction.destination.value];
|
||||
entry.destination = { id : +lastTransaction.destination.value, display : user.name };
|
||||
} else entry.destination = { display: lastTransaction.destination.value };
|
||||
}
|
||||
} else error(res);
|
||||
}
|
||||
|
||||
async function save(){
|
||||
let data = {
|
||||
...entry,
|
||||
purpose: entry.purpose.display
|
||||
}
|
||||
let url = api('accounting');
|
||||
let res = await post(url, data);
|
||||
if (res.ok) {
|
||||
yikes();
|
||||
if (new_account){
|
||||
router.navigate('/accounting');
|
||||
return;
|
||||
}
|
||||
//entry.tags = [];
|
||||
onSave();
|
||||
document.getElementById('date-input').focus();
|
||||
} else error(res);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
hr{
|
||||
grid-column: 1 / -1;
|
||||
margin: 0.5rem 0;
|
||||
border: 0;
|
||||
height: 1px;
|
||||
align-self: center;
|
||||
background: red;
|
||||
}
|
||||
</style>
|
||||
|
||||
<fieldset class="grid2 new_transaction">
|
||||
{#if new_account}
|
||||
<legend>{t('create_new_object',{object:t('account')})}</legend>
|
||||
<span style="display:none"></span>
|
||||
<span>{t('account name')}</span>
|
||||
<span>
|
||||
<input type="text" bind:value={entry.account.name} />
|
||||
</span>
|
||||
<span>{t('currency')}</span>
|
||||
<span>
|
||||
<input type="text" bind:value={entry.account.currency} />
|
||||
</span>
|
||||
<hr/>
|
||||
<span style="grid-column-end: span 2">{t('first transaction')}</span>
|
||||
{:else}
|
||||
<legend>{t('add_object',{object:t('transaction')})}</legend>
|
||||
<span style="display:none"></span>
|
||||
{/if}
|
||||
|
||||
<span>{t('date')}</span>
|
||||
<span>
|
||||
<input type="date" bind:value={entry.date} id="date-input" />
|
||||
</span>
|
||||
|
||||
<span>{t('amount')}</span>
|
||||
<span>
|
||||
<input type="number" bind:value={entry.amount} onkeyup={e => focusOnEnter(e,'source-input')} /> {entry.account.currency}
|
||||
</span>
|
||||
|
||||
<span>{t('source')}</span>
|
||||
<Autocomplete bind:candidate={entry.source} getCandidates={getSources} id="source-input" />
|
||||
|
||||
<span>{t('destination')}</span>
|
||||
<Autocomplete bind:candidate={entry.destination} getCandidates={getDestinations} onSelect={dst_selected} />
|
||||
|
||||
|
||||
<span>{t('purpose')}</span>
|
||||
<Autocomplete bind:candidate={entry.purpose} getCandidates={getPurposes} onCommit={gotoTags} id="purpose_input" />
|
||||
|
||||
<span>{t('tags')}</span>
|
||||
<Tags getCandidates={getAccountTags} module={null} bind:tags={entry.tags} onEmptyCommit={save} />
|
||||
|
||||
<span></span>
|
||||
<span>
|
||||
<button onclick={save}>{t('save')}</button>
|
||||
</span>
|
||||
</fieldset>
|
||||
@@ -44,9 +44,9 @@
|
||||
</span>
|
||||
|
||||
<ul>
|
||||
{#each accounts as account (account.id)}
|
||||
{#each accounts.toSorted((a,b) => a.name.localeCompare(b.name)) as account (account.id)}
|
||||
<li>
|
||||
<a {onclick} href="/account/{account.id}">{account.name} ({account.id})</a>
|
||||
<a {onclick} href="/account/{account.id}">{account.name}</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
authors = {...authors, ...data.authors};
|
||||
loader.offset += loader.limit;
|
||||
loader.active = false;
|
||||
console.log({authors});
|
||||
yikes();
|
||||
if (Object.keys(data.notes).length) onscroll(null); // when notes were received, check whether they fill up the page
|
||||
|
||||
@@ -78,4 +79,4 @@
|
||||
</svelte:head>
|
||||
|
||||
<svelte:window {onscroll} />
|
||||
<List {notes} />
|
||||
<List {notes} {authors} />
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
<legend class="entity" onclick={() => goToEntity(note)}>{title(note)}</legend>
|
||||
{/if}
|
||||
<legend class="time">
|
||||
{#if !module} {authors[note.user_id].name} – {/if}
|
||||
{note.timestamp.replace('T',' ')}
|
||||
{#if user.id == note.user_id}
|
||||
<button class="symbol" onclick={() => drop(note.id)}></button>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,7 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { useTinyRouter } from 'svelte-tiny-router';
|
||||
|
||||
import { api, get } from '../../urls.svelte.js';
|
||||
import { api, get, post } from '../../urls.svelte.js';
|
||||
import { error, yikes } from '../../warn.svelte';
|
||||
import { t } from '../../translations.svelte.js';
|
||||
import { user } from '../../user.svelte.js';
|
||||
@@ -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);
|
||||
@@ -104,11 +105,7 @@
|
||||
|
||||
async function saveTask(){
|
||||
const url = api('task/add');
|
||||
const resp = await fetch(url,{
|
||||
credentials : 'include',
|
||||
method : 'POST',
|
||||
body : JSON.stringify(task)
|
||||
});
|
||||
const resp = await post(url,task);
|
||||
if (resp.ok) {
|
||||
localStorage.removeItem(`task/${task.id}/description`);
|
||||
if (!assignee) { // if assignee is set, this form was opened within an external context. hence we don`t want to navigate somewhere else!
|
||||
@@ -158,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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useTinyRouter } from 'svelte-tiny-router';
|
||||
|
||||
import { dragged } from './dragndrop.svelte';
|
||||
import { api } from '../../urls.svelte';
|
||||
import { api, drop, patch, post } from '../../urls.svelte';
|
||||
import { error, yikes } from '../../warn.svelte';
|
||||
import { t } from '../../translations.svelte';
|
||||
import { timetrack } from '../../user.svelte';
|
||||
@@ -47,10 +47,7 @@
|
||||
async function deleteTask(){
|
||||
if (confirm(t('confirm_delete',{element:task.name}))){
|
||||
const url = api(`task/${task.id}`);
|
||||
const resp = await fetch(url,{
|
||||
credentials : 'include',
|
||||
method : 'DELETE'
|
||||
});
|
||||
const resp = await drop(url);
|
||||
if (resp.ok){
|
||||
deleted = true;
|
||||
} else {
|
||||
@@ -70,11 +67,7 @@
|
||||
ev.stopPropagation();
|
||||
if (dragged.element.id == task.id) return;
|
||||
const url = api(`task/${dragged.element.id}`);
|
||||
const resp = await fetch(url,{
|
||||
credentials : 'include',
|
||||
method : 'PATCH',
|
||||
body : JSON.stringify({ parent_task_id : task.id})
|
||||
});
|
||||
const resp = await patch(url, { parent_task_id : task.id});
|
||||
if (resp.ok) {
|
||||
yikes();
|
||||
} else {
|
||||
@@ -90,11 +83,7 @@
|
||||
show_closed : show_closed
|
||||
};
|
||||
if (task.show_closed) data.show_closed = true;
|
||||
const resp = await fetch(url,{
|
||||
credentials : 'include',
|
||||
method : 'POST',
|
||||
body : JSON.stringify(data)
|
||||
});
|
||||
const resp = await post(url,data);
|
||||
if (resp.ok){
|
||||
children = await resp.json();
|
||||
yikes();
|
||||
@@ -112,11 +101,7 @@
|
||||
|
||||
async function patchTask(changeset){
|
||||
const url = api(`task/${task.id}`);
|
||||
const resp = await fetch(url,{
|
||||
credentials : 'include',
|
||||
method : 'PATCH',
|
||||
body : JSON.stringify(changeset)
|
||||
});
|
||||
const resp = await patch(url,changeset);
|
||||
if (resp.ok){
|
||||
task = await resp.json();
|
||||
return true;
|
||||
@@ -145,9 +130,7 @@
|
||||
if (children && lastEvent && lastEvent.task) {
|
||||
if (lastEvent.event == 'delete' || lastEvent.task.parent_task_id != task.id){
|
||||
delete children[lastEvent.task.id];
|
||||
} else {
|
||||
children[lastEvent.task.id] = lastEvent.task;
|
||||
}
|
||||
} else children[lastEvent.task.id] = lastEvent.task;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { useTinyRouter } from 'svelte-tiny-router';
|
||||
|
||||
import { api } from '../../urls.svelte';
|
||||
import { api, get, patch, post } from '../../urls.svelte';
|
||||
import { error, yikes } from '../../warn.svelte';
|
||||
import { t } from '../../translations.svelte';
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
async function add(new_task_id){
|
||||
let url = api(`task/${new_task_id}`);
|
||||
let resp = await fetch(url,{ credentials : 'include' });
|
||||
let resp = await get(url);
|
||||
if (resp.ok){
|
||||
yikes();
|
||||
let newTask = await resp.json();
|
||||
@@ -27,7 +27,7 @@
|
||||
}
|
||||
task.required_tasks_ids.push(new_task_id);
|
||||
requiredTasks[new_task_id] = newTask;
|
||||
await patch();
|
||||
await update();
|
||||
delete candidates[new_task_id];
|
||||
} else {
|
||||
error(resp);
|
||||
@@ -59,17 +59,11 @@
|
||||
async function loadTasks(){
|
||||
if (!task || !task.required_tasks_ids || !task.required_tasks_ids.length) return;
|
||||
const url = api('task/list');
|
||||
const res = await fetch(url,{
|
||||
credentials : 'include',
|
||||
method : 'POST',
|
||||
body : JSON.stringify({ids:task.required_tasks_ids})
|
||||
});
|
||||
const res = await post(url,{ids:task.required_tasks_ids});
|
||||
if (res.ok){
|
||||
yikes();
|
||||
requiredTasks = await res.json();
|
||||
} else {
|
||||
error(resp);
|
||||
}
|
||||
} else error(resp);
|
||||
}
|
||||
|
||||
function oninput(){
|
||||
@@ -84,19 +78,15 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
async function patch(){
|
||||
async function update(){
|
||||
const url = api(`task/${task.id}`);
|
||||
const resp = await fetch(url,{
|
||||
credentials : 'include',
|
||||
method : 'PATCH',
|
||||
body : JSON.stringify({required_tasks_ids:task.required_tasks_ids})
|
||||
});
|
||||
const resp = await patch(url,{required_tasks_ids:task.required_tasks_ids});
|
||||
if (!resp.ok) error(resp);
|
||||
}
|
||||
|
||||
async function unlink(required_task){
|
||||
task.required_tasks_ids = task.required_tasks_ids.filter(item => item != required_task.id);
|
||||
patch();
|
||||
update();
|
||||
delete requiredTasks[required_task.id];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</script>
|
||||
|
||||
<ul>
|
||||
{#each sortedTasks as task}
|
||||
{#each sortedTasks as task (task.id)}
|
||||
<ListTask {states} {task} {lastEvent} siblings={tasks} {est_time} show_closed={show_closed || task.show_closed} />
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -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>
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onDestroy } from 'svelte';
|
||||
import { useTinyRouter } from 'svelte-tiny-router';
|
||||
|
||||
import { api, eventStream } from '../../urls.svelte';
|
||||
import { api, eventStream, get, patch, post } from '../../urls.svelte';
|
||||
import { error, yikes } from '../../warn.svelte';
|
||||
import { t } from '../../translations.svelte';
|
||||
import { timetrack } from '../../user.svelte.js';
|
||||
@@ -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`)
|
||||
@@ -99,32 +95,24 @@
|
||||
parent_task_id : +task.id,
|
||||
show_closed : show_closed
|
||||
};
|
||||
const resp = await fetch(url,{
|
||||
credentials : 'include',
|
||||
method : 'POST',
|
||||
body:JSON.stringify(data)
|
||||
});
|
||||
const resp = await post(url,data);
|
||||
if (resp.ok){
|
||||
yikes();
|
||||
children = await resp.json();
|
||||
} else {
|
||||
error(resp);
|
||||
}
|
||||
} else error(resp);
|
||||
}
|
||||
|
||||
async function loadParent(){
|
||||
const url = api(`task/${task.parent_task_id}`);
|
||||
const resp = await fetch(url,{credentials:'include'});
|
||||
const resp = await get(url);
|
||||
if (resp.ok){
|
||||
task.parent = await resp.json();
|
||||
} else {
|
||||
error(resp);
|
||||
}
|
||||
} else error(resp);
|
||||
}
|
||||
|
||||
async function loadTask(){
|
||||
const url = api(`task/${id}`);
|
||||
const resp = await fetch(url,{credentials:'include'});
|
||||
const resp = await get(url);
|
||||
if (resp.ok){
|
||||
yikes();
|
||||
task = await resp.json();
|
||||
@@ -132,20 +120,29 @@
|
||||
loadChildren();
|
||||
if (task.project_id) loadProject();
|
||||
if (task.parent_task_id) loadParent();
|
||||
} else {
|
||||
error(resp);
|
||||
}
|
||||
} else error(resp);
|
||||
}
|
||||
|
||||
async function loadProject(){
|
||||
const url = api(`project/${task.project_id}`);
|
||||
const resp = await fetch(url,{credentials:'include'});
|
||||
const resp = await get(url);
|
||||
if (resp.ok){
|
||||
project = await resp.json();
|
||||
yikes();
|
||||
} else {
|
||||
error(await resp.text());
|
||||
}
|
||||
} 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(){
|
||||
@@ -169,11 +166,7 @@
|
||||
|
||||
async function update(data){
|
||||
const url = api(`task/${id}`);
|
||||
const resp = await fetch(url,{
|
||||
credentials : 'include',
|
||||
method : 'PATCH',
|
||||
body : JSON.stringify(data)
|
||||
});
|
||||
const resp = await patch(url,data);
|
||||
if (resp.ok){
|
||||
yikes();
|
||||
let json = await resp.json();
|
||||
@@ -198,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;
|
||||
@@ -223,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})} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { useTinyRouter } from 'svelte-tiny-router';
|
||||
import { api, drop, patch, post } from '../../urls.svelte.js';
|
||||
import { api, drop, get, patch, post } from '../../urls.svelte.js';
|
||||
import { error, yikes } from '../../warn.svelte';
|
||||
import { t } from '../../translations.svelte.js';
|
||||
import { timetrack } from '../../user.svelte.js';
|
||||
@@ -41,10 +41,18 @@
|
||||
});
|
||||
|
||||
async function addTime(task_id){
|
||||
const url = api(`time/track_task/${task_id}`);
|
||||
const resp = await post(url,now()); // create new time or return time with assigned tasks
|
||||
let url = api(`time/track_task/${task_id}`);
|
||||
let resp = await post(url,now()); // create new time or return time with assigned tasks
|
||||
if (resp.ok) {
|
||||
const track = await resp.json();
|
||||
if (timetrack.running){
|
||||
url = api(`time/${timetrack.running.id}`);
|
||||
resp = await get(url);
|
||||
if (resp.ok){
|
||||
let previous = await resp.json();
|
||||
times[previous.id] = previous;
|
||||
}
|
||||
}
|
||||
timetrack.running = track;
|
||||
} else {
|
||||
error(resp);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -79,8 +79,15 @@ public class TimeModule extends BaseHandler implements TimeService {
|
||||
if (user.isEmpty()) return unauthorized(ex);
|
||||
var head = path.pop();
|
||||
return switch (head) {
|
||||
case STARTED -> getStartedTime(user.get(),ex);
|
||||
case null, default -> super.doGet(path,ex);
|
||||
case STARTED -> getStartedTime(user.get(),ex);
|
||||
case null -> super.doGet(path,ex);
|
||||
default -> {
|
||||
try {
|
||||
yield sendContent(ex,timeDb.load(Long.parseLong(head)));
|
||||
} catch (NumberFormatException ignored) {
|
||||
yield super.doGet(path,ex);
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (UmbrellaException e){
|
||||
return send(ex,e);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"abort": "abbrechen",
|
||||
"account": "Konto",
|
||||
"accounting": "Konten",
|
||||
"account name": "Konto-Name",
|
||||
"accounts": "Konten",
|
||||
"actions": "Aktionen",
|
||||
"add_login_service": "Login-Service anlegen",
|
||||
@@ -75,6 +76,7 @@
|
||||
"CREATE_USERS": "Nutzer anlegen",
|
||||
"create_pdf": "PDF erzeugen",
|
||||
"created_with": "erzeugt mit {tool} von {producer}",
|
||||
"currency": "Währung",
|
||||
"customer": "Kunde",
|
||||
"customer_address": "Adresse",
|
||||
"customer_email": "Emailadresse des Kunden",
|
||||
@@ -162,6 +164,7 @@
|
||||
"files": "Dateien",
|
||||
"filter": "Filter",
|
||||
"filter by tags": "Nach Tags filtern",
|
||||
"first_transaction": "erste Transaktion",
|
||||
"footer": "Fuß-Text",
|
||||
"foreign_id": "externe Kennung",
|
||||
"forgot_pass" : "Password vergessen?",
|
||||
@@ -325,6 +328,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",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"abort": "abort",
|
||||
"account": "account",
|
||||
"accounting": "accounting",
|
||||
"account name": "Account name",
|
||||
"accounts": "accounts",
|
||||
"actions": "actions",
|
||||
"add_login_service": "add login service",
|
||||
@@ -75,6 +76,7 @@
|
||||
"CREATE_USERS": "create users",
|
||||
"create_pdf": "create PDF",
|
||||
"created_with": "created with {tool} by {producer}",
|
||||
"currency": "Currency",
|
||||
"customer": "customer",
|
||||
"customer_address": "address",
|
||||
"customer_email": "customer email address",
|
||||
@@ -162,6 +164,7 @@
|
||||
"files": "files",
|
||||
"filter": "filter",
|
||||
"filter by tags": "filter by tags",
|
||||
"first_transaction": "first transaction",
|
||||
"footer": "footer",
|
||||
"foreign_id": "external ID",
|
||||
"forgot_pass" : "forgot password?",
|
||||
@@ -325,6 +328,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",
|
||||
@@ -458,11 +462,11 @@
|
||||
"wiki_pages": "wiki pages",
|
||||
|
||||
"year": "year",
|
||||
"You have been added to the new project '{project}', created by {user}:\n\n{body}": "You have been added to the new project '{project}', created by {user}:\n\n{body}",
|
||||
"You can view/edit this transaction at {base_url}/account/{id}": "Du kannst diese transaktion unter {base_url}/account/{id} ansehen",
|
||||
"You can view/edit this transaction at {base_url}/account/{id}": "You can view/edit this transaction at {base_url}/account/{id}",
|
||||
"You can view/edit this project at {base_url}/project/{id}/view": "You can view/edit this project at {base_url}/project/{id}/view",
|
||||
"You can view/edit this task at {base_url}/task/{id}/view": "You can view/edit this task at {base_url}/task/{id}/view",
|
||||
"You can view/edit this wiki page at {base_url}/wiki/{id}/view": "You can view/edit this wiki page at {base_url}/wiki/{id}/view",
|
||||
"You have been added to the new project '{project}', created by {user}:\n\n{body}": "You have been added to the new project '{project}', created by {user}:\n\n{body}",
|
||||
"You may change your notification settings at {base_url}/message/settings": "You may change your notification settings at {base_url}/message/settings .",
|
||||
"Your token to create a new password" : "Your token to create a new password",
|
||||
"your_profile": "your profile"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user