Compare commits

..

1 Commits

Author SHA1 Message Date
StephanRichter 4bdae72aba added error message
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-06-25 12:51:25 +02:00
51 changed files with 220 additions and 569 deletions
+5 -2
View File
@@ -41,6 +41,11 @@ jobs:
docker push ${{ secrets.REGISTRY_PATH }}/umbrella:${{ gitea.ref_name }} docker push ${{ secrets.REGISTRY_PATH }}/umbrella:${{ gitea.ref_name }}
docker push ${{ secrets.REGISTRY_PATH }}/umbrella:$TAG 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: Clean-Registry:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -48,10 +53,8 @@ jobs:
run: | run: |
TAGS="$(curl -s -u "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASS }}" https://${{ secrets.REGISTRY_PATH }}/v2/umbrella/tags/list | jq -r ".tags[]")" TAGS="$(curl -s -u "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASS }}" https://${{ secrets.REGISTRY_PATH }}/v2/umbrella/tags/list | jq -r ".tags[]")"
COUNT=$(echo "$TAGS" | wc -l) COUNT=$(echo "$TAGS" | wc -l)
echo found $COUNT tags: $TAGS
if [ $COUNT -gt 10 ]; then if [ $COUNT -gt 10 ]; then
REMAIN=$((COUNT - 10)) REMAIN=$((COUNT - 10))
echo $REMAIN tags will be kept!
echo "$TAGS" | head -n $REMAIN > /tmp/old_tags echo "$TAGS" | head -n $REMAIN > /tmp/old_tags
else else
echo less than 10 tags, skipping cleanup echo less than 10 tags, skipping cleanup
@@ -16,7 +16,7 @@ public interface AccountDb {
Collection<UmbrellaUser> getMembers(long accountId); Collection<UmbrellaUser> getMembers(long accountId);
Optional<Transaction> lastTransaction(long accountId, String source, String destination, Double amount); Optional<Transaction> lastTransaction(long accountId, String source, String dest, double amount);
Collection<Account> listAccounts(long userId); Collection<Account> listAccounts(long userId);
@@ -311,11 +311,12 @@ public class AccountingModule extends BaseHandler implements AccountingService {
var source = src.get(src.has(Field.ID) ? Field.ID : Field.DISPLAY).toString(); var source = src.get(src.has(Field.ID) ? Field.ID : Field.DISPLAY).toString();
if (!json.has(Field.DESTINATION)) throw missingField(Field.DESTINATION); if (!json.has(Field.DESTINATION)) throw missingField(Field.DESTINATION);
if (!(json.get(Field.DESTINATION) instanceof JSONObject dst)) throw invalidField(Field.SOURCE,JSON); if (!(json.get(Field.DESTINATION) instanceof JSONObject dst)) throw invalidField(Field.SOURCE,JSON);
String destination = dst.has(Field.ID) ? dst.get(Field.ID).toString() : dst.has(Field.DISPLAY) ? dst.get(Field.DISPLAY).toString() : null; var dest = dst.get(dst.has(Field.ID) ? Field.ID : Field.DISPLAY).toString();
Double amount = null; if (!json.has(Field.AMOUNT)) throw missingField(Field.AMOUNT);
if (json.has(Field.AMOUNT) && json.get(Field.AMOUNT) instanceof Number amt) amount = amt.doubleValue(); if (!(json.get(Field.AMOUNT) instanceof Number amt)) throw invalidField(Field.AMOUNT,Text.NUMBER);
var amount = amt.doubleValue();
var transaction = accountDb.lastTransaction(accountId, source, destination, amount); var transaction = accountDb.lastTransaction(accountId, source, dest, amount);
return transaction.isPresent() ? sendContent(ex,transaction.get()) : notFound(ex); return transaction.isPresent() ? sendContent(ex,transaction.get()) : notFound(ex);
} }
@@ -170,42 +170,22 @@ public class SqliteDb extends BaseDb implements AccountDb {
} }
@Override @Override
public Optional<Transaction> lastTransaction(long accountId, String source, String destination, Double amount) { public Optional<Transaction> lastTransaction(long accountId, String source, String dest, double amount) {
try { try {
var query = select(ALL).from(TABLE_TRANSACTIONS).where(ACCOUNT,equal(accountId)); var rs = select(ALL).from(TABLE_TRANSACTIONS)
if (source != null) query = query.where(SOURCE,equal(source)); .where(ACCOUNT,equal(accountId)).where(SOURCE,equal(source)).where(DESTINATION,equal(dest)).where(AMOUNT,equal(amount))
if (destination != null) query = query.where(DESTINATION,equal(destination)); .sort(ID+" DESC")
if (amount != null) query = query.where(AMOUNT,equal(amount)); .limit(1)
var rs = query.sort(ID+" DESC").limit(1).exec(db); .exec(db);
Transaction ta = null; Transaction ta = null;
if (rs.next()) ta = Transaction.of(rs); if (rs.next()) ta = Transaction.of(rs);
rs.close(); 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){ if (ta != null){
var tags = ta.tags(); 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); 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)); while (rs.next()) tags.add(rs.getString(1));
rs.close(); rs.close();
} }
return nullable(ta); return nullable(ta);
} catch (SQLException e) { } catch (SQLException e) {
throw failedToSearchDb(t(Text.ACCOUNTING)); throw failedToSearchDb(t(Text.ACCOUNTING));
@@ -14,6 +14,7 @@ import de.srsoftware.umbrella.bookmarks.BookmarkApi;
import de.srsoftware.umbrella.company.CompanyModule; import de.srsoftware.umbrella.company.CompanyModule;
import de.srsoftware.umbrella.contact.ContactModule; import de.srsoftware.umbrella.contact.ContactModule;
import de.srsoftware.umbrella.core.SettingsService; import de.srsoftware.umbrella.core.SettingsService;
import de.srsoftware.umbrella.core.Util;
import de.srsoftware.umbrella.core.exceptions.UmbrellaException; import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
import de.srsoftware.umbrella.documents.DocumentApi; import de.srsoftware.umbrella.documents.DocumentApi;
import de.srsoftware.umbrella.files.FileModule; import de.srsoftware.umbrella.files.FileModule;
@@ -33,6 +34,7 @@ import de.srsoftware.umbrella.translations.Translations;
import de.srsoftware.umbrella.user.UserModule; import de.srsoftware.umbrella.user.UserModule;
import de.srsoftware.umbrella.web.WebHandler; import de.srsoftware.umbrella.web.WebHandler;
import de.srsoftware.umbrella.wiki.WikiModule; import de.srsoftware.umbrella.wiki.WikiModule;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@@ -61,6 +63,8 @@ public class Application {
var port = config.get("umbrella.http.port", 8080); var port = config.get("umbrella.http.port", 8080);
var threads = config.get("umbrella.threads", 16); 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); var server = HttpServer.create(new InetSocketAddress(port), 0);
try { try {
new Translations(config).bindPath("/api/translations").on(server); new Translations(config).bindPath("/api/translations").on(server);
@@ -76,7 +80,7 @@ public class Application {
new DocumentApi(config).bindPath("/api/document").on(server); new DocumentApi(config).bindPath("/api/document").on(server);
new UserLegacy(config).bindPath("/legacy/user").on(server); new UserLegacy(config).bindPath("/legacy/user").on(server);
new NotesLegacy(config).bindPath("/legacy/notes").on(server); new NotesLegacy(config).bindPath("/legacy/notes").on(server);
new MarkdownApi(config).bindPath("/api/markdown").on(server); new MarkdownApi().bindPath("/api/markdown").on(server);
new NoteModule(config).bindPath("/api/notes").on(server); new NoteModule(config).bindPath("/api/notes").on(server);
new StockModule(config).bindPath("/api/stock").on(server); new StockModule(config).bindPath("/api/stock").on(server);
new PollModule(config).bindPath("/api/poll").on(server); new PollModule(config).bindPath("/api/poll").on(server);
@@ -1,6 +1,7 @@
/* © SRSoftware 2025 */ /* © SRSoftware 2025 */
package de.srsoftware.umbrella.core; package de.srsoftware.umbrella.core;
import de.srsoftware.umbrella.core.api.*; import de.srsoftware.umbrella.core.api.*;
public class ModuleRegistry { public class ModuleRegistry {
@@ -7,7 +7,6 @@ import static de.srsoftware.tools.PathHandler.GET;
import static de.srsoftware.tools.PathHandler.POST; import static de.srsoftware.tools.PathHandler.POST;
import static de.srsoftware.tools.Strings.hex; import static de.srsoftware.tools.Strings.hex;
import static de.srsoftware.umbrella.core.Errors.INVALID_URL; 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.Constants.TIME_FORMATTER;
import static de.srsoftware.umbrella.core.constants.Field.*; import static de.srsoftware.umbrella.core.constants.Field.*;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.serverError; import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.serverError;
@@ -15,6 +14,7 @@ import static java.lang.System.Logger.Level.*;
import static java.lang.System.Logger.Level.WARNING; import static java.lang.System.Logger.Level.WARNING;
import static java.nio.charset.StandardCharsets.UTF_8; import static java.nio.charset.StandardCharsets.UTF_8;
import com.xrbpowered.jparsedown.JParsedown;
import de.srsoftware.tools.Mappable; import de.srsoftware.tools.Mappable;
import de.srsoftware.tools.Query; import de.srsoftware.tools.Query;
import de.srsoftware.umbrella.core.exceptions.UmbrellaException; import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
@@ -31,12 +31,18 @@ import java.time.LocalDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern;
import org.json.JSONObject; import org.json.JSONObject;
public class Util { public class Util {
public static final System.Logger LOG = System.getLogger("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 String SHA1 = "SHA-1";
private static final MessageDigest SHA1_DIGEST; private static final MessageDigest SHA1_DIGEST;
private static final Map<Integer,String> umlCache = new HashMap<>();
private static final String SCRIPT = """ private static final String SCRIPT = """
<script src="http://127.0.0.1:8080/js/jspreadsheet-ce.js"></script> <script src="http://127.0.0.1:8080/js/jspreadsheet-ce.js"></script>
@@ -105,11 +111,71 @@ jspreadsheet(document.getElementById('spreadsheet'), {
public static HashMap<String, Object> mapMarkdown(String source){ public static HashMap<String, Object> mapMarkdown(String source){
var map = new HashMap<String,Object>(); var map = new HashMap<String,Object>();
map.put(SOURCE,source); map.put(SOURCE,source);
map.put(RENDERED,markdownService().markdown(source)); map.put(RENDERED,markdown(source));
return map; 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 { public static HttpURLConnection open(URL url) throws IOException {
var conn = (HttpURLConnection) url.openConnection(); var conn = (HttpURLConnection) url.openConnection();
@@ -183,6 +249,11 @@ jspreadsheet(document.getElementById('spreadsheet'), {
return new Hash(hex(bytes),SHA1); 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){ public static String dateTimeOf(long epochMilis){
return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMilis), ZoneId.systemDefault()).format(TIME_FORMATTER); return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMilis), ZoneId.systemDefault()).format(TIME_FORMATTER);
} }
@@ -2,5 +2,4 @@
package de.srsoftware.umbrella.core.api; package de.srsoftware.umbrella.core.api;
public interface MarkdownService { public interface MarkdownService {
String markdown(String source);
} }
@@ -91,7 +91,10 @@ public class Poll implements Mappable {
return Map.of( return Map.of(
ID,id, ID,id,
NAME,name, NAME,name,
DESCRIPTION, Util.mapMarkdown(description), DESCRIPTION, Map.of(
SOURCE,description,
RENDERED,Util.markdown(description)
),
STATUS,status STATUS,status
); );
} }
@@ -289,7 +292,10 @@ public class Poll implements Mappable {
ID, id, ID, id,
Field.OWNER, owner.toMap(), Field.OWNER, owner.toMap(),
NAME,name, NAME,name,
Field.DESCRIPTION, Util.mapMarkdown(description), Field.DESCRIPTION, Map.of(
Field.SOURCE,description,
Field.RENDERED,Util.markdown(description)
),
Field.OPTIONS, options.stream().collect(Collectors.toMap(Option::id,Option::toMap)), Field.OPTIONS, options.stream().collect(Collectors.toMap(Option::id,Option::toMap)),
Field.PERMISSION, mapPermissions(), Field.PERMISSION, mapPermissions(),
Field.PRIVATE, isPrivate, Field.PRIVATE, isPrivate,
-1
View File
@@ -1 +0,0 @@
*.db-journal
Binary file not shown.
Binary file not shown.
Binary file not shown.
-74
View File
@@ -1,74 +0,0 @@
{
"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.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

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 -2
View File
@@ -90,7 +90,7 @@
{#if messages.warning} {#if messages.warning}
<span class="warn">{@html messages.warning}</span> <span class="warn">{@html messages.warning}</span>
{/if} {/if}
<Route path="/" component={ProjectList} /> <Route path="/" component={User} />
<Route path="/account/:id" component={Account} /> <Route path="/account/:id" component={Account} />
<Route path="/accounting" component={Accounts} /> <Route path="/accounting" component={Accounts} />
<Route path="/accounting/new" component={NewAccount} /> <Route path="/accounting/new" component={NewAccount} />
@@ -116,7 +116,6 @@
<Route path="/project/:project_id/add_task" component={AddTask} /> <Route path="/project/:project_id/add_task" component={AddTask} />
<Route path="/project/:id/kanban" component={Kanban} /> <Route path="/project/:id/kanban" component={Kanban} />
<Route path="/project/:id/view" component={ViewPrj} /> <Route path="/project/:id/view" component={ViewPrj} />
<Route path="/project/:id" component={ViewPrj} />
<Route path="/search" component={Search} /> <Route path="/search" component={Search} />
<Route path="/stock" component={Stock} /> <Route path="/stock" component={Stock} />
<Route path="/stock/location/:location_id" component={Stock} /> <Route path="/stock/location/:location_id" component={Stock} />
+14 -17
View File
@@ -49,16 +49,12 @@
console.warn(`${candidate.display} selected, but onSelect not overridden!`) console.warn(`${candidate.display} selected, but onSelect not overridden!`)
} }
function disableDropdown(){
candidates = [];
selected = null;
}
async function ondblclick(evt){ async function ondblclick(evt){
const select = evt.target; const select = evt.target;
const idx = select.value; const idx = select.value;
candidate = candidates[idx]; candidate = candidates[idx];
disableDropdown(); candidates = [];
selected = null;
onSelect(candidate); onSelect(candidate);
} }
@@ -67,10 +63,6 @@
selected = null; selected = null;
} }
function onblur(ev){
setTimeout(disableDropdown,400);
}
async function onkeyup(ev){ async function onkeyup(ev){
if (ignore.includes(ev.key)) return; if (ignore.includes(ev.key)) return;
if (ev.key == 'ArrowDown'){ if (ev.key == 'ArrowDown'){
@@ -91,19 +83,22 @@
ev.preventDefault(); ev.preventDefault();
if (selected != null && selected < candidates.length) { if (selected != null && selected < candidates.length) {
candidate = candidates[selected]; candidate = candidates[selected];
disableDropdown(); candidates = [];
selected = null;
onSelect(candidate); onSelect(candidate);
return false; return false;
} }
if (ev.key == 'Enter') { if (ev.key == 'Enter') {
disableDropdown(); candidates = [];
selected = null;
if (onCommit(candidate)) candidate = { display : '' }; if (onCommit(candidate)) candidate = { display : '' };
} }
return false; return false;
} }
if (ev.key == 'Escape'){ if (ev.key == 'Escape'){
ev.preventDefault(); ev.preventDefault();
disableDropdown(); candidates = [];
selected = null;
return false; return false;
} }
@@ -115,8 +110,10 @@
function select(index){ function select(index){
candidate = candidates[index]; candidate = candidates[index];
<disableDropdown></disableDropdown>(); selected = null;
candidates = [];
onSelect(candidate); onSelect(candidate);
} }
function scrollTo(index){ function scrollTo(index){
@@ -148,11 +145,11 @@
</style> </style>
<span class="autocomplete"> <span class="autocomplete">
<input type="text" bind:value={candidate.display} {onkeyup} autofocus={autofocus} {id} {onblur} /> <input type="text" bind:value={candidate.display} {onkeyup} autofocus={autofocus} {id} />
{#if candidates && candidates.length > 0} {#if candidates && candidates.length > 0}
<ul bind:this={list_elem} class="suggestions" tabindex="-1"> <ul bind:this={list_elem} class="suggestions">
{#each candidates as candidate,i} {#each candidates as candidate,i}
<li class="option {selected==i?'highlight':''}" tabindex="-1" onclick={e => select(i)} ondblclick={e => select(i)}>{candidate.display}</li> <li class="option {selected==i?'highlight':''}" onclick={e => select(i)} ondblclick={e => select(i)}>{candidate.display}</li>
{/each} {/each}
</ul> </ul>
{/if} {/if}
@@ -4,7 +4,7 @@
import { error, yikes } from '../../warn.svelte'; import { error, yikes } from '../../warn.svelte';
import { t } from '../../translations.svelte'; import { t } from '../../translations.svelte';
import EntryForm from './add_entry_new.svelte'; import EntryForm from './add_entry.svelte';
import Transaction from './transaction.svelte'; import Transaction from './transaction.svelte';
let { id } = $props(); let { id } = $props();
@@ -163,5 +163,5 @@
</table> </table>
</fieldset> </fieldset>
<EntryForm {account} {onSave} {users} /> <EntryForm {account} {onSave} />
{/if} {/if}
@@ -1,205 +0,0 @@
<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')} />&nbsp;{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>
+2 -2
View File
@@ -44,9 +44,9 @@
</span> </span>
<ul> <ul>
{#each accounts.toSorted((a,b) => a.name.localeCompare(b.name)) as account (account.id)} {#each accounts as account (account.id)}
<li> <li>
<a {onclick} href="/account/{account.id}">{account.name}</a> <a {onclick} href="/account/{account.id}">{account.name} ({account.id})</a>
</li> </li>
{/each} {/each}
</ul> </ul>
+13 -7
View File
@@ -1,7 +1,7 @@
<script> <script>
import { useTinyRouter } from 'svelte-tiny-router'; import { useTinyRouter } from 'svelte-tiny-router';
import { api, post } from '../../urls.svelte.js'; import { api } from '../../urls.svelte.js';
import { error, yikes } from '../../warn.svelte'; import { error, yikes } from '../../warn.svelte';
import { t } from '../../translations.svelte.js'; import { t } from '../../translations.svelte.js';
@@ -21,9 +21,13 @@
}); });
async function onsubmit(ev){ async function onsubmit(ev){
if (ev) ev.preventDefault(); ev.preventDefault();
const url = api('project'); const url = api('project');
var resp = await post(url,project); var resp = await fetch(url,{
credentials : 'include',
method : 'POST',
body : JSON.stringify(project)
});
if (resp.ok){ if (resp.ok){
var newProject = await resp.json(); var newProject = await resp.json();
router.navigate(`/project/${newProject.id}/view`); router.navigate(`/project/${newProject.id}/view`);
@@ -46,7 +50,8 @@
label{ display: block } label{ display: block }
</style> </style>
<fieldset> <form {onsubmit}>
<fieldset>
<legend> <legend>
{t('create_new_project')} {t('create_new_project')}
</legend> </legend>
@@ -105,11 +110,12 @@
{t('tags')} {t('tags')}
</th> </th>
<td> <td>
<Tags module={null} bind:tags={project.tags} onEmptyCommit={onsubmit} /> <Tags module="project" bind:tags={project.tags} />
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</fieldset> </fieldset>
<button onclick={onsubmit} disabled={!ready}>{t('create')}</button> <button type="submit" disabled={!ready}>{t('create')}</button>
</fieldset> </fieldset>
</form>
+1 -2
View File
@@ -79,7 +79,6 @@
task.members[assignee] = project.members[assignee]; task.members[assignee] = project.members[assignee];
task.members[assignee].permission = { name : "ASSIGNEE", code : 3 } task.members[assignee].permission = { name : "ASSIGNEE", code : 3 }
} }
if (task.taks.length < 1) task.tags = project.tags;
yikes(); yikes();
} else { } else {
error(resp); error(resp);
@@ -155,7 +154,7 @@
</div> </div>
<div>{t('tags')}</div> <div>{t('tags')}</div>
<div> <div>
<Tags module={null} bind:tags={task.tags} onEmptyCommit={saveTask} /> <Tags module="task" bind:tags={task.tags} />
</div> </div>
{#if extendedSettings} {#if extendedSettings}
+1 -1
View File
@@ -137,7 +137,7 @@
<legend>{t('state_open')}</legend> <legend>{t('state_open')}</legend>
{#if sorted} {#if sorted}
{#each sorted as task} {#each sorted as task}
{#if task.status < 60 && task.status >= 20 && match(task)} {#if 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} > <div href={`/task/${task.id}/view`} title={task.description.source} task_id={task.id} {onclick} {oncontextmenu} {ontouchstart} {ontouchend} onmousedown={ontouchstart} onmouseup={ontouchend} >
{task.name} {task.name}
</div> </div>
+3 -11
View File
@@ -1,7 +1,7 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { useTinyRouter } from 'svelte-tiny-router'; import { useTinyRouter } from 'svelte-tiny-router';
import { api, drop, get, patch, post } from '../../urls.svelte.js'; import { api, drop, patch, post } from '../../urls.svelte.js';
import { error, yikes } from '../../warn.svelte'; import { error, yikes } from '../../warn.svelte';
import { t } from '../../translations.svelte.js'; import { t } from '../../translations.svelte.js';
import { timetrack } from '../../user.svelte.js'; import { timetrack } from '../../user.svelte.js';
@@ -41,18 +41,10 @@
}); });
async function addTime(task_id){ async function addTime(task_id){
let url = api(`time/track_task/${task_id}`); const url = api(`time/track_task/${task_id}`);
let resp = await post(url,now()); // create new time or return time with assigned tasks const resp = await post(url,now()); // create new time or return time with assigned tasks
if (resp.ok) { if (resp.ok) {
const track = await resp.json(); 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; timetrack.running = track;
} else { } else {
error(resp); error(resp);
@@ -3,7 +3,9 @@ package de.srsoftware.umbrella.legacy;
import static de.srsoftware.tools.Optionals.nullable; import static de.srsoftware.tools.Optionals.nullable;
import static de.srsoftware.umbrella.core.ModuleRegistry.*; 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.constants.Field.TOKEN; import static de.srsoftware.umbrella.core.constants.Field.TOKEN;
import static de.srsoftware.umbrella.core.constants.Field.URI; import static de.srsoftware.umbrella.core.constants.Field.URI;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.invalidField; import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.invalidField;
@@ -70,7 +72,7 @@ public class NotesLegacy extends BaseHandler {
new Tag("fieldset") new Tag("fieldset")
.add(new Tag("legend").content(authorName)) .add(new Tag("legend").content(authorName))
.add(new Tag("legend").content(note.timestamp().format(DateTimeFormatter.ISO_DATE_TIME))) .add(new Tag("legend").content(note.timestamp().format(DateTimeFormatter.ISO_DATE_TIME)))
.add(new Tag("div").content(markdownService().markdown(note.text()))) .add(new Tag("div").content(markdown(note.text())))
.addTo(html); .addTo(html);
} }
return sendContent(ex,html.toString(2)); return sendContent(ex,html.toString(2));
@@ -3,50 +3,21 @@ package de.srsoftware.umbrella.markdown;
import static de.srsoftware.tools.MimeType.MIME_HTML; import static de.srsoftware.tools.MimeType.MIME_HTML;
import static de.srsoftware.umbrella.core.ModuleRegistry.userService; 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.sun.net.httpserver.HttpExchange;
import com.xrbpowered.jparsedown.JParsedown;
import de.srsoftware.configuration.Configuration;
import de.srsoftware.tools.Path; import de.srsoftware.tools.Path;
import de.srsoftware.umbrella.core.BaseHandler; import de.srsoftware.umbrella.core.BaseHandler;
import de.srsoftware.umbrella.core.ModuleRegistry; import de.srsoftware.umbrella.core.ModuleRegistry;
import de.srsoftware.umbrella.core.Util;
import de.srsoftware.umbrella.core.api.MarkdownService; import de.srsoftware.umbrella.core.api.MarkdownService;
import de.srsoftware.umbrella.core.exceptions.UmbrellaException; import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
import java.io.File;
import java.io.IOException; 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 { 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(); 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); ModuleRegistry.add(this);
} }
@@ -57,98 +28,11 @@ public class MarkdownApi extends BaseHandler implements MarkdownService {
var user = userService().refreshSession(ex); var user = userService().refreshSession(ex);
if (user.isEmpty()) throw UmbrellaException.forbidden("You must be logged in to use the markdown renderer!"); if (user.isEmpty()) throw UmbrellaException.forbidden("You must be logged in to use the markdown renderer!");
var rendered = markdown(body(ex)); var rendered = Util.markdown(body(ex));
ex.getResponseHeaders().add(CONTENT_TYPE,MIME_HTML); ex.getResponseHeaders().add(CONTENT_TYPE,MIME_HTML);
return sendContent(ex,rendered); return sendContent(ex,rendered);
} catch (UmbrellaException e){ } catch (UmbrellaException e){
return send(ex,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;
}
}
} }
@@ -21,6 +21,7 @@ import static de.srsoftware.umbrella.messagebus.MessageBus.messageBus;
import static de.srsoftware.umbrella.messagebus.events.Event.EventType.CREATE; import static de.srsoftware.umbrella.messagebus.events.Event.EventType.CREATE;
import static de.srsoftware.umbrella.project.Constants.PERMISSIONS; import static de.srsoftware.umbrella.project.Constants.PERMISSIONS;
import static de.srsoftware.umbrella.task.Constants.*; import static de.srsoftware.umbrella.task.Constants.*;
import static java.lang.System.Logger.Level.ERROR;
import static java.lang.System.Logger.Level.WARNING; import static java.lang.System.Logger.Level.WARNING;
import static java.net.URLDecoder.decode; import static java.net.URLDecoder.decode;
import static java.nio.charset.StandardCharsets.UTF_8; import static java.nio.charset.StandardCharsets.UTF_8;
@@ -198,6 +199,7 @@ public class TaskModule extends BaseHandler implements TaskService {
var projectTasks = taskDb.listProjectTasks(project.id(),null, false); var projectTasks = taskDb.listProjectTasks(project.id(),null, false);
var mapped = projectTasks.values().stream().collect(Collectors.toMap(Task::id,Task::toMap)); var mapped = projectTasks.values().stream().collect(Collectors.toMap(Task::id,Task::toMap));
var roots = new HashMap<Long,Map<String,Object>>(); var roots = new HashMap<Long,Map<String,Object>>();
LOG.log(ERROR,"getParentCandidates not fully functional");
for (var map : mapped.values()){ for (var map : mapped.values()){
if (!(map.get(ID) instanceof Long id)) continue; if (!(map.get(ID) instanceof Long id)) continue;
if (id == taskId) continue; if (id == taskId) continue;
@@ -80,14 +80,7 @@ public class TimeModule extends BaseHandler implements TimeService {
var head = path.pop(); var head = path.pop();
return switch (head) { return switch (head) {
case STARTED -> getStartedTime(user.get(),ex); case STARTED -> getStartedTime(user.get(),ex);
case null -> super.doGet(path,ex); case null, default -> 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){ } catch (UmbrellaException e){
return send(ex,e); return send(ex,e);
-3
View File
@@ -17,7 +17,6 @@
"abort": "abbrechen", "abort": "abbrechen",
"account": "Konto", "account": "Konto",
"accounting": "Konten", "accounting": "Konten",
"account name": "Konto-Name",
"accounts": "Konten", "accounts": "Konten",
"actions": "Aktionen", "actions": "Aktionen",
"add_login_service": "Login-Service anlegen", "add_login_service": "Login-Service anlegen",
@@ -76,7 +75,6 @@
"CREATE_USERS": "Nutzer anlegen", "CREATE_USERS": "Nutzer anlegen",
"create_pdf": "PDF erzeugen", "create_pdf": "PDF erzeugen",
"created_with": "erzeugt mit {tool} von {producer}", "created_with": "erzeugt mit {tool} von {producer}",
"currency": "Währung",
"customer": "Kunde", "customer": "Kunde",
"customer_address": "Adresse", "customer_address": "Adresse",
"customer_email": "Emailadresse des Kunden", "customer_email": "Emailadresse des Kunden",
@@ -164,7 +162,6 @@
"files": "Dateien", "files": "Dateien",
"filter": "Filter", "filter": "Filter",
"filter by tags": "Nach Tags filtern", "filter by tags": "Nach Tags filtern",
"first_transaction": "erste Transaktion",
"footer": "Fuß-Text", "footer": "Fuß-Text",
"foreign_id": "externe Kennung", "foreign_id": "externe Kennung",
"forgot_pass" : "Password vergessen?", "forgot_pass" : "Password vergessen?",
+2 -5
View File
@@ -17,7 +17,6 @@
"abort": "abort", "abort": "abort",
"account": "account", "account": "account",
"accounting": "accounting", "accounting": "accounting",
"account name": "Account name",
"accounts": "accounts", "accounts": "accounts",
"actions": "actions", "actions": "actions",
"add_login_service": "add login service", "add_login_service": "add login service",
@@ -76,7 +75,6 @@
"CREATE_USERS": "create users", "CREATE_USERS": "create users",
"create_pdf": "create PDF", "create_pdf": "create PDF",
"created_with": "created with {tool} by {producer}", "created_with": "created with {tool} by {producer}",
"currency": "Currency",
"customer": "customer", "customer": "customer",
"customer_address": "address", "customer_address": "address",
"customer_email": "customer email address", "customer_email": "customer email address",
@@ -164,7 +162,6 @@
"files": "files", "files": "files",
"filter": "filter", "filter": "filter",
"filter by tags": "filter by tags", "filter by tags": "filter by tags",
"first_transaction": "first transaction",
"footer": "footer", "footer": "footer",
"foreign_id": "external ID", "foreign_id": "external ID",
"forgot_pass" : "forgot password?", "forgot_pass" : "forgot password?",
@@ -462,11 +459,11 @@
"wiki_pages": "wiki pages", "wiki_pages": "wiki pages",
"year": "year", "year": "year",
"You can view/edit this transaction at {base_url}/account/{id}": "You can view/edit this transaction at {base_url}/account/{id}", "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 project at {base_url}/project/{id}/view": "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 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 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 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 .", "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 token to create a new password" : "Your token to create a new password",
"your_profile": "your profile" "your_profile": "your profile"
@@ -74,7 +74,6 @@ tr:hover .taglist .tag button {
color: black; color: black;
} }
code,
.code{ .code{
color: chocolate; color: chocolate;
} }
+1 -2
View File
@@ -15,10 +15,9 @@ body {
background-position: 98% 70px; background-position: 98% 70px;
background-attachment: fixed; background-attachment: fixed;
} }
.code,
code { code {
font-size: 16px; font-size: 16px;
font-family: monospace;
} }
fieldset { fieldset {