Merge remote-tracking branch 'origin/master' into upstream_master

# Conflicts:
#	api/js/jsapi/egw_app.ts
This commit is contained in:
milan 2024-06-13 16:55:50 +02:00
commit 5dc07b36c6
74 changed files with 2744 additions and 711 deletions

View File

@ -3250,8 +3250,6 @@ class addressbook_ui extends addressbook_bo
// need to load list's app.js now, as exec calls header before other app can include it
// Framework::includeJS('/'.$crm_list.'/js/app.js');
// Load CRM code
Framework::includeJS('.','CRM','addressbook');
$content['view_sidebox'] = addressbook_hooks::getViewDOMID($content['id'], $crm_list);
$this->tmpl->exec('addressbook.addressbook_ui.view',$content,$sel_options,$readonlys,array(
'id' => $content['id'],

View File

@ -1110,6 +1110,62 @@ class AddressbookApp extends EgwApp
return false;
}
/**
* Get email addresses from selected contacts
*
* @param selected
* @param {string[]} email_fields
* @param {string} name_field
* @returns {Promise<string[]>}
*/
async _getEmails(selected, email_fields = ["email"], name_field = 'n_fn') : Promise<string[]>
{
if(email_fields.length == 0)
{
return [];
}
// Check for all selected, don't resolve until all done
let nm = this.et2.getWidgetById('nm');
let all = new Promise(function(resolve)
{
let fetching = fetchAll(selected, nm, ids => {resolve(ids.map(function(num) {return {id: 'addressbook::' + num};}))});
if(!fetching)
{
resolve(selected);
}
});
let awaited = await all;
// Go through selected & pull email addresses from data
let emails = [];
for(let i = 0; i < awaited.length; i++)
{
// Pull data from global cache
const data = egw.dataGetUIDdata(awaited[i].id) || {data: {}};
let emailAddresses = email_fields.map(field =>
{
return data.data[field];
})
// prefix email with full name
let personal = data.data[name_field] || '';
if(personal.match(/[^a-z0-9. -]/i))
{
personal = '"' + personal.replace(/"/, '\\"') + '"';
}
//remove comma in personal as it will confilict with mail content comma seperator in the process
personal = personal.replace(/,/g, '');
emailAddresses.forEach(mail =>
{
emails.push((personal ? personal + ' <' : '') + mail + (personal ? '>' : ''));
});
}
return emails;
}
/**
* Merge the selected contacts into the target document.
*

View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE overlay PUBLIC "-//EGroupware GmbH//eTemplate 2.0//EN" "https://www.egroupware.org/etemplate2.0.dtd">
<overlay>
<template id="addressbook.view" version="1.9.001" slot="left">
<grid class="addressbook_view" width="100%">
<columns>
<column width="70"/>
<column/>
</columns>
<rows>
<row span="all">
<et2-hbox>
<et2-lavatar src="@photo" lname="@n_fn"></et2-lavatar>
<et2-vbox>
<et2-description id="n_fn" class="addressbook_sidebox_name"></et2-description>
<et2-description id="org_name" class="addressbook_sidebox_org"></et2-description>
<et2-description id="org_unit"></et2-description>
<et2-description id="adr_one_locality"></et2-description>
</et2-vbox>
</et2-hbox>
</row>
<row>
<et2-description span="2" value="Phone numbers" class="addressbook_sidebox_header"></et2-description>
</row>
<row>
<et2-description for="tel_work" value="Business"></et2-description>
<et2-url-phone id="tel_work" readonly="true"></et2-url-phone>
</row>
<row>
<et2-description for="tel_cell" value="Mobile phone"></et2-description>
<et2-url-phone id="tel_cell" readonly="true"></et2-url-phone>
</row>
<row>
<et2-description for="tel_home" value="Private"></et2-description>
<et2-url-phone id="tel_home" readonly="true"></et2-url-phone>
</row>
<row>
<et2-description for="tel_fax" value="Fax"></et2-description>
<et2-url-phone id="tel_fax" readonly="true"></et2-url-phone>
</row>
<row>
<et2-description span="2" value="EMail &amp; Internet" class="addressbook_sidebox_header"></et2-description>
</row>
<row>
<et2-description for="email" value="EMail"></et2-description>
<et2-url-email id="email" readonly="true"></et2-url-email>
</row>
<row>
<et2-description for="url" value="URL"></et2-description>
<et2-url id="url" readonly="true"></et2-url>
</row>
<row class="toolbox">
<et2-hbox>
<et2-button id="button[edit]" label="open" image="edit" onclick="app.addressbook.view_actions"></et2-button>
<et2-button id="button[copy]" label="copy" image="copy" onclick="app.addressbook.view_actions"></et2-button>
<et2-button id="button[close]" statustext="close" readonly="false" image="close" onclick="app.addressbook.view_actions"></et2-button>
<et2-button id="button[delete]" label="delete" image="delete" onclick="app.addressbook.view_actions" noSubmit="true"></et2-button>
</et2-hbox>
</row>
</rows>
</grid>
</template>
</overlay>

View File

@ -423,7 +423,14 @@ class AdminApp extends EgwApp
if(!_data || _data.type != undefined) return;
// Insert the content, etemplate will load into it
jQuery(this.ajax_target.getDOMNode()).append(typeof _data === 'string' ? _data : _data[0]);
if(typeof _data === "string" || typeof _data[0] !== "undefined")
{
jQuery(this.ajax_target.getDOMNode()).append(typeof _data === 'string' ? _data : _data[0]);
}
else if(typeof _data.DOMNodeID == "string")
{
this.ajax_target.setAttribute("id", _data.DOMNodeID);
}
}
/**

View File

@ -83,7 +83,7 @@
</grid>
</template>
<template id="admin.index" template="" lang="" group="0" version="1.9.001">
<tree autoloading="admin_ui::ajax_tree" id="tree" onclick="app.admin.run" parent_node="admin_tree_target" std_images="bullet"/>
<tree slot="left" autoloading="admin_ui::ajax_tree" id="tree" onclick="app.admin.run" parent_node="admin_tree_target" std_images="bullet"/>
<nextmatch id="nm" template="admin.index.rows" header_left="admin.index.add"/>
<nextmatch id="groups" template="admin.index.group" class="hide"/>
<iframe frameborder="1" height="100%" id="iframe" scrolling="auto" width="100%" disabled="true"/>

View File

@ -0,0 +1,145 @@
import {customElement} from "lit/decorators/custom-element.js";
import {Et2Widget} from "../Et2Widget/Et2Widget";
import {css, html, LitElement} from "lit";
import shoelace from "../Styles/shoelace";
import {Et2VfsSelectDialog} from "../Et2Vfs/Et2VfsSelectDialog";
import {property} from "lit/decorators/property.js";
import {Et2Dialog} from "./Et2Dialog";
import {state} from "lit/decorators/state.js";
@customElement("et2-merge-dialog")
export class Et2MergeDialog extends Et2Widget(LitElement)
{
static get styles()
{
return [
super.styles,
shoelace,
css`
:host {
}
et2-details::part(content) {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
`,
];
}
@property()
application : string
@property()
path : string
// Can't merge "& send" if no email template selected
@state()
canEmail = false;
private get dialog() : Et2VfsSelectDialog
{
return <Et2VfsSelectDialog><unknown>this.shadowRoot.querySelector("et2-vfs-select-dialog");
}
public async getComplete() : Promise<{
documents : { path : string, mime : string }[],
options : { [key : string] : string | boolean }
}>
{
await this.updateComplete;
const [button, value] = await this.dialog.getComplete();
if(!button)
{
return {documents: [], options: this.optionValues};
}
const documents = [];
Array.from(<ArrayLike<string>>value).forEach(value =>
{
const fileInfo = this.dialog.fileInfo(value) ?? [];
documents.push({path: value, mime: fileInfo.mime})
});
let options = this.optionValues;
if(button == Et2Dialog.OK_BUTTON)
{
options.download = true;
}
return {documents: documents, options: options};
}
public get optionValues()
{
const optionValues = {
download: false
};
this.dialog.querySelectorAll(":not([slot='footer'])").forEach(e =>
{
if(typeof e.getValue == "function")
{
optionValues[e.getAttribute("id")] = e.getValue() === "true" ? true : e.getValue();
}
});
return optionValues;
}
private option(component_name)
{
return this.dialog.querySelector("et2-details > [id='" + component_name + "']");
}
protected handleFileSelect(event)
{
// Disable PDF checkbox for only email files selected
let canPDF = false;
const oldCanEmail = this.canEmail;
this.canEmail = false;
this.dialog.value.forEach(path =>
{
if(this.dialog.fileInfo(path).mime !== "message/rfc822")
{
canPDF = true;
}
else
{
this.canEmail = true;
}
});
this.option("pdf").disabled = !canPDF;
this.requestUpdate("canEmail", oldCanEmail);
}
render()
{
return html`
<et2-vfs-select-dialog
class=egw_app_merge_document"
path=${this.path}
multiple="true"
buttonLabel=${this.egw().lang("Download")}
.title="${this.egw().lang("Insert in document")}"
.open=${true}
@et2-select=${this.handleFileSelect}
>
${this.canEmail ?
html`
<et2-button slot="footer" id="send" label=${this.egw().lang("Merge & send")} image="mail"
noSubmit="true"></et2-button> ` :
html`
<et2-button slot="footer" id="send" label=${this.egw().lang("Merge")} image="etemplate/merge"
noSubmit="true"></et2-button>`
}
<et2-details>
<span slot="summary">${this.egw().lang("Merge options")}</span>
<et2-checkbox label=${this.egw().lang("Save as PDF")} id="pdf"></et2-checkbox>
<et2-checkbox id="link"
label="${this.egw().lang("Link to each entry")}"
></et2-checkbox>
<et2-checkbox label=${this.egw().lang("Merge individually")} id="individual"></et2-checkbox>
</et2-details>
</et2-vfs-select-dialog>`;
}
}

View File

@ -17,6 +17,7 @@ import {Et2Image} from "../Et2Image/Et2Image";
import {Et2Dialog} from "../Et2Dialog/Et2Dialog";
import {SlMenuItem} from "@shoelace-style/shoelace";
import {cssImage} from "../Et2Widget/Et2Widget";
import {Favorite} from "./Favorite";
/**
* Favorites widget, designed for use in the nextmatch header
@ -108,7 +109,7 @@ export class Et2Favorites extends Et2DropdownButton implements et2_INextmatchHea
// Favorites are prefixed in preferences
public static readonly PREFIX = "favorite_";
protected static readonly ADD_VALUE = "~add~";
static readonly ADD_VALUE = "~add~";
private favSortedList : any = [];
private _preferred : string;
@ -193,8 +194,7 @@ export class Et2Favorites extends Et2DropdownButton implements et2_INextmatchHea
if(changedProperties.has("app"))
{
this._preferred = <string>this.egw().preference(this.defaultPref, this.app);
this.__select_options = this._load_favorites(this.app);
this.requestUpdate("select_options");
this._load_favorites(this.app);
}
}
@ -205,87 +205,26 @@ export class Et2Favorites extends Et2DropdownButton implements et2_INextmatchHea
*/
_load_favorites(app)
{
// Default blank filter
let favorites : any = {
'blank': {
name: this.egw().lang("No filters"),
state: {}
}
};
// Load saved favorites
this.favSortedList = [];
let preferences : any = this.egw().preference("*", app);
for(let pref_name in preferences)
Favorite.load(this.egw(), app).then((favorites) =>
{
if(pref_name.indexOf(Et2Favorites.PREFIX) == 0 && typeof preferences[pref_name] == 'object')
let options = [];
Object.keys(favorites).forEach((name) =>
{
let name = pref_name.substr(Et2Favorites.PREFIX.length);
favorites[name] = preferences[pref_name];
// Keep older favorites working - they used to store nm filters in 'filters',not state
if(preferences[pref_name]["filters"])
{
favorites[pref_name]["state"] = preferences[pref_name]["filters"];
}
}
if(pref_name == 'fav_sort_pref')
options.push(Object.assign({value: name, label: favorites[name].name || name}, favorites[name]));
})
// Only add 'Add current' if we have a nextmatch
if(this._nextmatch)
{
this.favSortedList = preferences[pref_name];
//Make sure sorted list is always an array, seems some old fav are not array
if(!Array.isArray(this.favSortedList))
{
this.favSortedList = this.favSortedList.split(',');
}
options.push({value: Et2Favorites.ADD_VALUE, label: this.egw().lang('Add current')});
}
}
for(let name in favorites)
{
if(this.favSortedList.indexOf(name) < 0)
{
this.favSortedList.push(name);
}
}
this.egw().set_preference(this.app, 'fav_sort_pref', this.favSortedList);
if(this.favSortedList.length > 0)
{
let sortedListObj = {};
for(let i = 0; i < this.favSortedList.length; i++)
{
if(typeof favorites[this.favSortedList[i]] != 'undefined')
{
sortedListObj[this.favSortedList[i]] = favorites[this.favSortedList[i]];
}
else
{
this.favSortedList.splice(i, 1);
this.egw().set_preference(this.app, 'fav_sort_pref', this.favSortedList);
}
}
favorites = Object.assign(sortedListObj, favorites);
}
let options = [];
Object.keys(favorites).forEach((name) =>
{
options.push(Object.assign({value: name, label: favorites[name].name || name}, favorites[name]));
})
// Only add 'Add current' if we have a nextmatch
if(this._nextmatch)
{
options.push({value: Et2Favorites.ADD_VALUE, label: this.egw().lang('Add current')});
}
this.requestUpdate("select_options");
return options;
this.__select_options = options
this.requestUpdate("select_options");
});
}
public load_favorites(app)
{
this.__select_options = this._load_favorites(app);
this.requestUpdate("select_options");
this._load_favorites(app);
}
/**
@ -349,7 +288,7 @@ export class Et2Favorites extends Et2DropdownButton implements et2_INextmatchHea
}
this._value = ev.detail.item.value;
this._apply_favorite(ev.detail.item.value);
Favorite.applyFavorite(this.egw(), this.app, ev.detail.item.value);
}
/**
@ -360,7 +299,7 @@ export class Et2Favorites extends Et2DropdownButton implements et2_INextmatchHea
*/
protected _handleClick(event : MouseEvent)
{
this._apply_favorite(this.preferred);
Favorite.applyFavorite(this.egw, this.app, this.preferred);
}
/**
@ -407,9 +346,7 @@ export class Et2Favorites extends Et2DropdownButton implements et2_INextmatchHea
trash.remove();
// Delete preference server side
let request = this.egw().json("EGroupware\\Api\\Framework::ajax_set_favorite",
[this.app, fav.name, "delete", "" + fav.group, '']).sendRequest();
request.then(response =>
Favorite.remove(this.egw(), this.app, line.value).then(response =>
{
line.classList.remove("loading");
@ -442,24 +379,6 @@ export class Et2Favorites extends Et2DropdownButton implements et2_INextmatchHea
return false;
}
/**
* Apply a favorite to the app or nextmatch
*
* @param {string} favorite_name
* @protected
*/
protected _apply_favorite(favorite_name : string)
{
let fav = favorite_name == "blank" ? {} : this.favoriteByID(favorite_name);
// use app[appname].setState if available to allow app to overwrite it (eg. change to non-listview in calendar)
//@ts-ignore TS doesn't know about window.app
if(typeof window.app[this.app] != 'undefined')
{
//@ts-ignore TS doesn't know about window.app
window.app[this.app].setState(fav);
}
}
/**
* Set the nextmatch to filter
* From et2_INextmatchHeader interface
@ -477,8 +396,7 @@ export class Et2Favorites extends Et2DropdownButton implements et2_INextmatchHea
}
// Re-generate filter list so we can add 'Add current'
this.__select_options = this._load_favorites(this.app);
this.requestUpdate("select_options");
this._load_favorites(this.app);
}
}

View File

@ -0,0 +1,5 @@
```html:preview
<et2-favorites-menu>
</et2-favorites-menu>
```

View File

@ -0,0 +1,139 @@
import {css, html, LitElement, nothing, TemplateResult} from "lit";
import {customElement} from "lit/decorators/custom-element.js";
import {Et2Widget} from "../Et2Widget/Et2Widget";
import {Favorite} from "./Favorite";
import {property} from "lit/decorators/property.js";
import {until} from "lit/directives/until.js";
import {repeat} from "lit/directives/repeat.js";
/**
* @summary A menu listing a user's favorites. Populated from the user's preferences.
*
* @dependency sl-menu
* @dependency sl-menu-item
* @dependency sl-menu-label
* @dependency et2-image
*
* @slot - Add additional menu items
*/
@customElement("et2-favorites-menu")
export class Et2FavoritesMenu extends Et2Widget(LitElement)
{
static get styles()
{
return [
super.styles,
css`
:host {
min-width: 15em;
}
et2-image[src="trash"] {
display: none;
}
sl-menu-item:hover et2-image[src="trash"] {
display: initial;
}`
]
};
/**
* The current application we're showing favorites for.
*
* @type {string}
*/
@property()
application : string;
private favorites : { [name : string] : Favorite } = {
'blank': {
name: typeof this.egw()?.lang == "function" ? this.egw().lang("No filters") : "No filters",
state: {},
group: false
}
};
private loadingPromise = Promise.resolve();
connectedCallback()
{
super.connectedCallback();
if(this.application)
{
this.loadingPromise = Favorite.load(this.egw(), this.application).then((favorites) =>
{
this.favorites = favorites;
});
}
}
handleSelect(event)
{
Favorite.applyFavorite(this.egw(), this.application, event.detail.item.value);
}
handleDelete(event)
{
// Don't trigger click
event.stopPropagation();
const menuItem = event.target.closest("sl-menu-item");
menuItem.setAttribute("loading", "");
const favoriteName = menuItem.value;
// Remove from server
Favorite.remove(this.egw(), this.application, favoriteName).then(() =>
{
// Remove from widget
delete this.favorites[favoriteName];
this.requestUpdate();
});
this.requestUpdate();
}
protected menuItemTemplate(name : string, favorite : Favorite) : TemplateResult
{
let is_admin = (typeof this.egw()?.app == "function") && (typeof this.egw()?.app('admin') != "undefined");
//@ts-ignore option.group does not exist
let icon = (favorite.group !== false && !is_admin || ['blank', '~add~'].includes(name)) ? "" : html`
<et2-image slot="suffix" src="trash" icon @click=${this.handleDelete}
statustext="${this.egw()?.lang("Delete") ?? "Delete"}"></et2-image>`;
return html`
<sl-menu-item value="${name}">
${icon}
${favorite.name}
</sl-menu-item>`;
}
protected loadingTemplate()
{
return html`
<sl-menu-item loading>${typeof this.egw()?.lang == "function" ? this.egw().lang("Loading") : "Loading"}
</sl-menu-item>`;
}
render()
{
let content = this.loadingPromise.then(() =>
{
return html`
<sl-menu
@sl-select=${this.handleSelect}
>
${this.label ? html`
<sl-menu-label>${this.label}</sl-menu-label>` : nothing}
${repeat(Object.keys(this.favorites), (i) => this.menuItemTemplate(i, this.favorites[i]))}
<slot></slot>
</sl-menu>
`;
});
return html`
${until(content, this.loadingTemplate())}
`;
}
}

View File

@ -0,0 +1,109 @@
export class Favorite
{
name : string
state : object
group : number | false
// Favorites are prefixed in preferences
public static readonly PREFIX = "favorite_";
public static readonly ADD_VALUE = "~add~";
/**
* Load favorites from preferences
*
* @param app String Load favorites from this application
*/
static async load(egw, app : string) : Promise<{ [name : string] : Favorite }>
{
// Default blank filter
let favorites : { [name : string] : Favorite } = {
'blank': {
name: egw.lang("No filters"),
state: {},
group: false
}
};
// Load saved favorites
let sortedList = [];
let preferences : any = await egw.preference("*", app, true);
for(let pref_name in preferences)
{
if(pref_name.indexOf(Favorite.PREFIX) == 0 && typeof preferences[pref_name] == 'object')
{
let name = pref_name.substr(Favorite.PREFIX.length);
favorites[name] = preferences[pref_name];
// Keep older favorites working - they used to store nm filters in 'filters',not state
if(preferences[pref_name]["filters"])
{
favorites[pref_name]["state"] = preferences[pref_name]["filters"];
}
}
if(pref_name == 'fav_sort_pref')
{
sortedList = preferences[pref_name];
//Make sure sorted list is always an array, seems some old fav are not array
if(!Array.isArray(sortedList) && typeof sortedList == "string")
{
// @ts-ignore What's the point of a typecheck if IDE still errors
sortedList = sortedList.split(',');
}
}
}
for(let name in favorites)
{
if(sortedList.indexOf(name) < 0)
{
sortedList.push(name);
}
}
egw.set_preference(app, 'fav_sort_pref', sortedList);
if(sortedList.length > 0)
{
let sortedListObj = {};
for(let i = 0; i < sortedList.length; i++)
{
if(typeof favorites[sortedList[i]] != 'undefined')
{
sortedListObj[sortedList[i]] = favorites[sortedList[i]];
}
else
{
sortedList.splice(i, 1);
egw.set_preference(app, 'fav_sort_pref', sortedList);
}
}
favorites = Object.assign(sortedListObj, favorites);
}
return favorites;
}
static async applyFavorite(egw, app : string, favoriteName : string)
{
const favorites = await Favorite.load(egw, app);
let fav = favoriteName == "blank" ? {} : favorites[favoriteName] ?? {};
// use app[appname].setState if available to allow app to overwrite it (eg. change to non-listview in calendar)
//@ts-ignore TS doesn't know about window.app
if(typeof window.app[app] != 'undefined')
{
//@ts-ignore TS doesn't know about window.app
window.app[app].setState(fav);
}
}
static async remove(egw, app, favoriteName)
{
const favorites = await Favorite.load(egw, app);
let fav = favorites[favoriteName];
if(!fav)
{
return Promise.reject("No such favorite");
}
return egw.request("EGroupware\\Api\\Framework::ajax_set_favorite",
[app, favoriteName, "delete", "" + fav.group, '']);
}
}

View File

@ -437,7 +437,10 @@ export class Et2LinkTo extends Et2InputWidget(LitElement)
handleEntrySelected(event)
{
// Could be the app, could be they selected an entry
if(event.target == this.select._searchNode)
if(event.target == this.select && (
typeof this.select.value == "string" && this.select.value ||
typeof this.select.value == "object" && this.select.value.id
))
{
this.classList.add("can_link");
this.link_button.focus();

View File

@ -184,6 +184,7 @@ export class Et2Number extends Et2Textbox
private handleScroll(e)
{
if (this.disabled) return;
const old_value = this.value;
let min = parseFloat(this.min ?? Number.MIN_SAFE_INTEGER);
if(Number.isNaN(min))
@ -208,7 +209,7 @@ export class Et2Number extends Et2Textbox
return '';
}
return html`
return this.disabled ? '' : html`
<et2-button-scroll class="et2-number__scrollbuttons" slot="suffix"
part="scroll"
@et2-scroll=${this.handleScroll}></et2-button-scroll>`;

View File

@ -966,13 +966,17 @@ export class Et2Tree extends Et2WidgetWithSelectMixin(LitElement)
* @return {TreeItemData} node with the given _id or null
* @private
*/
private _search(_id: string, data: TreeItemData[]): TreeItemData
private _search(_id: string|number, data: TreeItemData[]): TreeItemData
{
let res: TreeItemData = null
if (_id == undefined)
{
return null
}
if (typeof _id === "number")
{
_id = _id + "";
}
for (const value of data)
{
if (value.id === _id)

View File

@ -8,7 +8,7 @@
*/
import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
import {html, LitElement, nothing, PropertyValues, TemplateResult} from "lit";
import {html, LitElement, nothing, PropertyValues, render, TemplateResult} from "lit";
import shoelace from "../Styles/shoelace";
import styles from "./Et2VfsSelect.styles";
import {property} from "lit/decorators/property.js";
@ -110,7 +110,8 @@ export class Et2VfsSelectDialog
label: "Spreadsheets"
},
{value: "image/", label: "Images"},
{value: "video/", label: "Videos"}
{value: "video/", label: "Videos"},
{value: "message/rfc822", label: "Email"}
];
/** The select's help text. If you need to display HTML, use the `help-text` slot instead. */
@ -249,11 +250,26 @@ export class Et2VfsSelectDialog
public setPath(path)
{
const oldValue = this.path;
// Selection doesn't stay across sub-dirs. Notify user we dropped them.
if(this.value.length && path != oldValue)
{
const length = this.value.length;
this.value = [];
this.updateComplete.then(() =>
{
render(html`
<sl-alert duration="5000" closable open>
<sl-icon slot="icon" name="info-circle"></sl-icon>
${this.egw().lang("Selection of files can only be done in one folder. %1 files unselected.", length)}
</sl-alert>`, <HTMLElement><unknown>this);
});
}
if(path == '..')
{
path = this.dirname(this.path);
}
const oldValue = this.path;
this._pathNode.value = this.path = path;
this.requestUpdate("path", oldValue);
this.currentResult = null;
@ -745,7 +761,7 @@ export class Et2VfsSelectDialog
image="filemanager/fav_filter" noSubmit="true"
@click=${() => this.setPath("/apps/favorites")}
></et2-button>
<et2-select id="app" emptyLabel="Applications" noLang="1"
<et2-select id="app" emptyLabel="${this.egw().lang("Applications")}" noLang="1"
.select_options=${this._appList}
@change=${(e) => this.setPath("/apps/" + e.target.value)}
>
@ -813,7 +829,7 @@ export class Et2VfsSelectDialog
const buttons = [
{id: "ok", label: this.buttonLabel, image: image, button_id: Et2Dialog.OK_BUTTON},
{id: "cancel", label: "cancel", image: "cancel", button_id: Et2Dialog.CANCEL_BUTTON}
{id: "cancel", label: this.egw().lang("cancel"), image: "cancel", button_id: Et2Dialog.CANCEL_BUTTON}
];
return html`
@ -882,6 +898,7 @@ export class Et2VfsSelectDialog
></et2-vfs-path>
</div>
${this.searchResultsTemplate()}
<slot></slot>
<sl-visually-hidden>
<et2-label for="mimeFilter">${this.egw().lang("mime filter")}</et2-label>
</sl-visually-hidden>
@ -901,7 +918,6 @@ export class Et2VfsSelectDialog
>
${this.mimeOptionsTemplate()}
</et2-select>
<slot></slot>
<div
part="form-control-help-text"
id="help-text"

View File

@ -6,6 +6,7 @@ import {state} from "lit/decorators/state.js";
import {classMap} from "lit/directives/class-map.js";
import shoelace from "../Styles/shoelace";
import styles from "./Et2VfsSelectRow.styles";
import {SearchResultElement} from "../Et2Widget/SearchMixin";
/**
* @summary Shows one file in the Et2VfsSelectDialog list
@ -16,7 +17,7 @@ import styles from "./Et2VfsSelectRow.styles";
* @csspart base - The components base wrapper.
* @csspart checked-icon - The checked icon, an <sl-icon> element.
*/
export class Et2VfsSelectRow extends Et2Widget(LitElement)
export class Et2VfsSelectRow extends Et2Widget(LitElement) implements SearchResultElement
{
static get styles()
{
@ -43,6 +44,11 @@ export class Et2VfsSelectRow extends Et2Widget(LitElement)
this.setAttribute('aria-selected', 'false');
}
@property()
get label()
{
return this.value?.label || this.value?.name || "";
}
private handleMouseEnter()
{
this.hasHover = true;

View File

@ -15,7 +15,7 @@ import {egw} from "../../jsapi/egw_global";
registerIconLibrary('default', {
resolver: name =>
{
return typeof egw !== "undefined" ? (egw.image(name) ?? `${egw.webserverUrl || ""}/node_modules/@shoelace-style/shoelace/dist/assets/icons/${name}.svg`) : ''
return typeof egw !== "undefined" && typeof egw.image == "function" ? (egw.image(name) ?? `${egw.webserverUrl || ""}/node_modules/@shoelace-style/shoelace/dist/assets/icons/${name}.svg`) : ''
},
});

View File

@ -197,7 +197,8 @@ export class et2_grid extends et2_DOMWidget implements et2_IDetachedDOM, et2_IAl
class: "",
valign: "top",
span: "1",
disabled: false
disabled: false,
style: ""
};
}
@ -285,6 +286,7 @@ export class et2_grid extends et2_DOMWidget implements et2_IDetachedDOM, et2_IAl
rowDataEntry["valign"] = et2_readAttrWithDefault(node, "valign", "");
rowDataEntry["span"] = et2_readAttrWithDefault(node, "span", "1");
rowDataEntry["part"] = et2_readAttrWithDefault(node, "part", "body");
rowDataEntry["style"] = et2_readAttrWithDefault(node, "style", "");
const id = et2_readAttrWithDefault(node, "id", "");
if(id)
@ -549,6 +551,10 @@ export class et2_grid extends et2_DOMWidget implements et2_IDetachedDOM, et2_IAl
{
this._getCell(cells, x, y).rowData.class = this.getArrayMgr("content").expandName(this._getCell(cells, x, y).rowData.class);
}
if(this._getCell(cells, x, y).rowData.style)
{
this._getCell(cells, x, y).rowData.style = this.getArrayMgr("content").expandName(this._getCell(cells, x, y).rowData.style);
}
}
if(!nm && typeof cell.disabled === 'string')
@ -771,6 +777,11 @@ export class et2_grid extends et2_DOMWidget implements et2_IDetachedDOM, et2_IAl
tr.attr("valign", this.rowData[y].valign);
}
if(this.rowData[y].style)
{
tr.attr("style", this.rowData[y].style);
}
if(this.rowData[y].id)
{
tr.attr("id", this.rowData[y].id);
@ -1226,5 +1237,6 @@ interface RowEntry
class : string, // "",
valign : string, // "top",
span : string | number, // "1",
disabled : boolean // false
disabled : boolean, // false
style: string
}

View File

@ -48,6 +48,12 @@ export class et2_radiobox extends et2_inputWidget
"type": "string",
"default": "",
"description": "What should be displayed when readonly and not selected"
},
name: {
"name": "Name of radio-group",
"type": "string",
"default": "",
"description": "Used to group radio-buttons, defaults to id of radio-button"
}
};
@ -96,7 +102,10 @@ export class et2_radiobox extends et2_inputWidget
super.set_id(_id);
this.dom_id = this.dom_id.replace('[]', '')+'-'+this.options.set_value;
if (this.input) this.input.attr('id', this.dom_id);
if (this.input) {
this.input.attr('id', this.dom_id);
if (this.options.name) this.input.attr('name', this.options.name);
}
}
/**
@ -122,7 +131,7 @@ export class et2_radiobox extends et2_inputWidget
{
this.getRoot().iterateOver(function(radio)
{
if (radio.id == this.id)
if (radio.id == this.id && (!this.options.name || this.options.name == radio.options.name))
{
radio.input.prop('checked', _value == radio.options.set_value);
}
@ -141,7 +150,8 @@ export class et2_radiobox extends et2_inputWidget
this.getRoot().iterateOver(function(radio)
{
values.push(radio.options.set_value);
if (radio.id == this.id && radio.input && radio.input.prop('checked'))
if (radio.id == this.id && radio.input && (!this.options.name || this.options.name == radio.options.name) &&
radio.input.prop('checked'))
{
val = radio.options.set_value;
}
@ -486,5 +496,4 @@ export class et2_radioGroup extends et2_valueWidget implements et2_IDetachedDOM
}
// No such tag as 'radiogroup', but it needs something
et2_register_widget(et2_radioGroup, ["radiogroup"]);
et2_register_widget(et2_radioGroup, ["radiogroup"]);

View File

@ -49,11 +49,13 @@ import './Et2Date/Et2DateTimeReadonly';
import './Et2Date/Et2DateTimeToday';
import './Et2Description/Et2Description';
import './Et2Dialog/Et2Dialog';
import './Et2Dialog/Et2MergeDialog';
import './Et2DropdownButton/Et2DropdownButton';
import './Et2Email/Et2Email';
import './Expose/Et2ImageExpose';
import './Expose/Et2DescriptionExpose';
import './Et2Favorites/Et2Favorites';
import './Et2Favorites/Et2FavoritesMenu';
import './Et2Image/Et2Image';
import './Et2Image/Et2AppIcon';
import './Et2Avatar/Et2LAvatar';
@ -707,6 +709,11 @@ export class etemplate2
etemplate2._byTemplate[_name].push(this);
// Read the XML structure of the requested template
if(etemplate2.templates[this.name].hasAttribute("slot"))
{
this.DOMContainer.setAttribute("slot", etemplate2.templates[this.name].getAttribute("slot"));
}
this._widgetContainer.loadFromXML(etemplate2.templates[this.name]);
console.timeEnd("loadFromXML");
console.time("deferred");
@ -1642,7 +1649,7 @@ export class etemplate2
}
else
{
egw.debug("error", "Could not find target node %s", data.DOMNodeId);
egw.debug("error", "Could not find target node %s", data.DOMNodeID);
}
}

View File

@ -17,13 +17,15 @@ import {et2_createWidget} from "../etemplate/et2_core_widget";
import type {IegwAppLocal} from "./egw_global";
import Sortable from 'sortablejs/modular/sortable.complete.esm.js';
import {et2_valueWidget} from "../etemplate/et2_core_valueWidget";
import {nm_action} from "../etemplate/et2_extension_nextmatch_actions";
import {fetchAll, nm_action} from "../etemplate/et2_extension_nextmatch_actions";
import {Et2Dialog} from "../etemplate/Et2Dialog/Et2Dialog";
import {Et2Favorites} from "../etemplate/Et2Favorites/Et2Favorites";
import {loadWebComponent} from "../etemplate/Et2Widget/Et2Widget";
import {Et2VfsSelectDialog} from "../etemplate/Et2Vfs/Et2VfsSelectDialog";
import {Et2Checkbox} from "../etemplate/Et2Checkbox/Et2Checkbox";
import type {EgwAction} from "../egw_action/EgwAction";
import {Et2MergeDialog} from "../etemplate/Et2Dialog/Et2MergeDialog";
import {EgwActionObject} from "../egw_action/EgwActionObject";
import type {Et2Details} from "../etemplate/Layout/Et2Details/Et2Details";
import {Et2Checkbox} from "../etemplate/Et2Checkbox/Et2Checkbox";
import {AcSelect} from "../../../achelper/js/AcSelect/AcSelect";
/**
@ -839,28 +841,90 @@ export abstract class EgwApp
let split = _selected[i].id.split("::");
ids.push(split[1]);
}
let document = await this._getMergeDocument(nm?.getInstanceManager(), _action);
if(!document.document)
let document = await this._getMergeDocument(nm?.getInstanceManager(), _action, _selected);
if(!document.documents || document.documents.length == 0)
{
return;
}
let vars = {
..._action.data.merge_data,
document: document.document,
pdf: document.pdf ?? false,
options: document.options,
select_all: all,
id: ids
};
if(document.mime == "message/rfc822")
if(document.options.link)
{
vars.options.app = this.appname;
}
// Just one file, an email - merge & edit or merge & send
if(document.documents.length == 1 && document.documents[0].mime == "message/rfc822")
{
vars.document = document.documents[0].path;
return this._mergeEmail(_action.clone(), vars);
}
else
{
vars.id = JSON.stringify(ids);
vars.document = document.documents.map(f => f.path);
}
if(document.documents.length == 1 && !document.options.individual)
{
// Only 1 document, we can open it
vars.id = JSON.stringify(ids);
this.egw.open_link(this.egw.link('/index.php', vars), '_blank');
}
else
{
// Multiple files, or merging individually - will result in multiple documents that we can't just open
vars.menuaction = vars.menuaction.replace("merge_entries", "ajax_merge_multiple");
vars.menuaction += "&merge=" + vars.merge;
let mergedFiles = [];
// Check for an email template - all other files will be merged & attached
let email = document.documents.find(f => f.mime == "message/rfc822");
// Can we do this in one, or do we have to split it up for feedback?
if(!vars.options.individual && (!email || email && !all && ids.length == 1))
{
vars.options.open_email = !vars.options.download && typeof email != "undefined";
// Handle it all on the server in one request
this.egw.loading_prompt(vars.menuaction, true);
mergedFiles = await this.egw.request(vars.menuaction, [vars.id, vars.document, vars.options]);
this.egw.loading_prompt(vars.menuaction, false);
// One entry, email template selected - we can open that in the compose window
if(email)
{
debugger;
}
else
{
this.egw.message(mergedFiles, "success");
}
}
else
{
// Merging documents, merge email, attach to email, send.
// Handled like this here so we can give feedback, server could do it all in one request
let idGroup = await new Promise<string[]>((resolve) =>
{
if(all)
{
fetchAll(ids, nm, idsArr => resolve(vars.options.individual ? idsArr : [idsArr]));
}
else
{
resolve(vars.options.individual ? ids : [ids])
}
});
Et2Dialog.long_task(null /*done*/, this.egw.lang("Merging"),
email ? this.egw.lang("Merging into %1 and sending", email.path) : this.egw.lang("Merging into %1", vars.document.join(", ")),
vars.menuaction,
idGroup.map((ids) => {return [Array.isArray(ids) ? ids : [ids], vars.document, vars.options];}), this.egw
);
}
}
this.egw.open_link(this.egw.link('/index.php', vars), '_blank');
}
/**
@ -870,10 +934,9 @@ export abstract class EgwApp
* @protected
*/
protected _getMergeDocument(et2?, action? : EgwAction) : Promise<{
document : string,
pdf : boolean,
mime : string
protected _getMergeDocument(et2?, action? : EgwAction, selected? : EgwActionObject[]) : Promise<{
documents : { path : string; mime : string }[];
options : { [p : string] : string | boolean }
}>
{
let path = action?.data?.merge_data?.directory ?? "";
@ -886,51 +949,33 @@ export abstract class EgwApp
d = "/" + d;
}
});
let fileSelect = <Et2VfsSelectDialog><unknown>loadWebComponent('et2-vfs-select-dialog', {
class: "egw_app_merge_document",
title: this.egw.lang("Insert in document"),
mode: "open",
path: path ?? dirs?.pop() ?? "",
open: true
}, et2.widgetContainer);
let fileSelect = <Et2MergeDialog><unknown>loadWebComponent('et2-merge-dialog', {
application: this.appname,
path: dirs.pop() || ""
}, et2?.widgetContainer ?? null);
if(!et2)
{
document.body.append(fileSelect);
}
let pdf = <Et2Checkbox><unknown>loadWebComponent("et2-checkbox", {
slot: "footer",
label: "As PDF"
}, fileSelect);
// Disable PDF checkbox for emails
fileSelect.addEventListener("et2-select", e =>
// Customize dialog
fileSelect.updateComplete.then(() =>
{
let canPDF = true;
fileSelect.value.forEach(path =>
{
if(fileSelect.fileInfo(path).mime == "message/rfc822")
{
canPDF = false;
}
});
pdf.disabled = !canPDF;
});
return fileSelect.getComplete().then((values) =>
{
if(!values[0])
{
return {document: '', pdf: false, mime: ""};
}
// Start details open when you have multiple selected
// @ts-ignore
(<Et2Details>fileSelect.shadowRoot.querySelector('et2-details')).open = selected.length > 1;
const value = values[1].pop() ?? "";
const fileInfo = fileSelect.fileInfo(value) ?? {};
fileSelect.remove();
return {document: value, pdf: pdf.getValue(), mime: fileInfo.mime ?? ""};
// Disable individual when only one entry is selected
// @ts-ignore
(<Et2Checkbox>fileSelect.shadowRoot.querySelector("et2-details > [id='individual']")).disabled = selected.length == 1;
});
// Remove when done
fileSelect.getComplete().then(() => {fileSelect.remove();});
return fileSelect.getComplete();
}
/**
* Merging into an email
* Merge into an email, then open it in compose for a single, send directly for multiple
*
* @param {object} data
* @protected

View File

@ -533,20 +533,22 @@ egw.extend('json', egw.MODULE_WND_LOCAL, function(_app, _wnd)
// The ajax request has completed, get just the data & pass it on
if(response && response.response)
{
let data = [];
for(let value of response.response)
{
if(value.type && value.type === "data" && typeof value.data !== "undefined")
{
// Data was packed in response
return value.data;
data.push(value.data);
}
else if (value && typeof value.type === "undefined" && typeof value.data === "undefined")
{
// Just raw data
return value;
data.push(value);
}
}
return undefined;
// Normally only 1 data, but multiple etemplate.exec calls can give multiple
return data.length > 1 ? data : data[0];
}
return response;
});

View File

@ -100,10 +100,16 @@ egw.extend('preferences', egw.MODULE_GLOBAL, function()
if (_callback === false) return undefined;
const request = this.json('EGroupware\\Api\\Framework::ajax_get_preference', [_app], _callback, _context);
const promise = request.sendRequest(typeof _callback !== 'undefined', 'GET');
if (typeof prefs[_app] === 'undefined') prefs[_app] = {};
if (typeof prefs[_app] === 'undefined') prefs[_app] = promise;
if (_callback === true) return promise.then(() => this.preference(_name, _app));
if (typeof _callback === 'function') return false;
}
else if (typeof prefs[_app] === 'object' && typeof prefs[_app].then === 'function')
{
if (_callback === false) return undefined;
if (_callback === true) return prefs[_app].then(() => this.preference(_name, _app));
if (typeof _callback === 'function') return false;
}
let ret;
if (_name === "*")
{

View File

@ -119,6 +119,7 @@ all addressbooks groupdav de Alle Adressbücher
all categories common de Alle Kategorien
all days common de Alle Tage
all fields common de Alle Felder
all files common de Alle Dateien
all in one groupdav de Gemeinsam in einem
all languages common de Alle Sprachen
all operations save the template! common de Alle Operation speichern das Template!
@ -443,6 +444,7 @@ document '%1' does not exist or is not readable for you! preferences de Das Doku
document properties common de Dokument Eigenschaften
document title: common de Titel des Dokuments:
documentation common de Dokumentation
documents common de Dokumente
doesn't matter common de Spielt keine Rolle
domain common de Domain
domain name for mail-address, eg. "%1" common de Domainname für E-Mail Adresse, z.B. "%1"
@ -490,6 +492,7 @@ el salvador common de EL SALVADOR
element role title common de Element Rolle Titel
email common de E-Mail
email-address of the user, eg. "%1" common de E-Mail-Adresse des Benutzers, z.B. "%1"
emails common de E-Mails
embeded css styles, eg. '.red { background: red; }' (note the '.' before the class-name) or '@import url(...)' (class names are global for the whole page!) common de Eingebettete CSS Stile, zb. '.red { background: red; }' (man beachte den '.' vor der CSS class) oder '@import url(...)' (Angaben gelten für die gesamte Seite!)
empty file common de Datei leeren
enable javascript onchange submit common de JavaScript absenden bei Änderung (onChange) aktivieren
@ -700,6 +703,7 @@ if you use "2-factor-authentication", please enter the code here. common de Wenn
image common de Grafik
image directory relative to document root (use / !), example: common de Bildverzeichnis entsprechend zur Dokumentroot (benutze / !), Beispiel:
image url common de Bild-URL
images common de Bilder
import common de Import
import an etemplate from a xml-file common de Importiert ein eTemplate aus einer XML-Datei
import table-definitions from existing db-table common de Importiert die Tabellen-Definition aus einer bestehenden Datenbank-Tabelle
@ -811,6 +815,7 @@ link is appended to mail allowing recipients to download or modify up to date ve
link is appended to mail allowing recipients to download up to date version of files common de Link wird an die E-Mail angefügt und erlaubt Adressaten den aktuellen Inhalt der an gehangenen Dateien herunter zu laden
link target %1 not found! common de Keine Verknüpfung zu %1 gefunden!
link title of current record common de Link-Titel des aktuellen Datensatzes
link to each entry common de Verknüpfung zum einzelnen Eintrag
linkapps common de Verknüpfung Anwendungen
linked common de Verknüpft
linkentry common de Verknüpfung Eintrag
@ -866,6 +871,10 @@ maybe common de Vieleicht
mayotte common de MAYOTTE
medium common de Mittel
menu common de Menü
merge common de Zusammenführen
merge & send common de Zusammenführen & Versenden
merge individually common de einzeln zusammenführen
merge options common de Optionen beim Zusammenführen
merged document filename preferences de Dateiname des zusammengeführten Dokuments
message common de Nachricht
message ... common de Nachricht ...
@ -1184,6 +1193,7 @@ savant2 version differs from savant2 wrapper. <br/>this version: %1 <br/>savants
save common de Speichern
save all common de Alle speichern
save as common de Speichern unter
save as PDF common de Als PDF speichern
save as zip common de Als ZIP-Datei speichern
save selected columns as default preference for all users. common de Speichert die ausgewählten Spalten als Vorgabe für alle Benutzer.
save the changes made and close the window common de Speichert die Änderungen uns schließt das Fenster
@ -1264,6 +1274,7 @@ select work email address common de Geschäftl. E-Mail-Adresse auswählen
select year common de Jahr auswählen
selectbox common de Auswahlbox
selection common de Auswahl
selection of files can only be done in one folder. %1 files unselected. common de Die Auswahl von Dateien kann nur in einem Ordner erfolgen. %1 Dateien nicht ausgewählt.
send common de Senden
send succeeded to %1 common de Versand erfolgreich zu %1
senegal common de SENEGAL
@ -1342,6 +1353,7 @@ spain common de SPANIEN
span common de Überspannt
span, class common de Span, Class
special characters common de Sonderzeichen
spreadsheets common de Tabellenkalkulationen
sri lanka common de SRI LANKA
stack common de Stapel
start a new search, cancel this link common de Neue Suche starten, diese Verknüpfung abbrechen
@ -1503,6 +1515,7 @@ version-number, should be in the form: major.minor.revision.number (eg. 0.9.13.0
vertical alignment of row common de Vertikale Ausrichtung der Zeile
vfs upload directory common de Dateimanager Upload-Ordner
video tutorials common de Video-Tutorials
videos common de Videos
viet nam common de VIETNAM
view common de Anzeigen
view linked %1 entries common de Verknüpfte %1 anzeigen

View File

@ -119,6 +119,7 @@ all addressbooks groupdav en All addressbooks
all categories common en All categories
all days common en All days
all fields common en All fields
all files common en All files
all in one groupdav en All in one
all languages common en All languages
all operations save the template! common en All operations save the template!
@ -444,6 +445,7 @@ document '%1' does not exist or is not readable for you! preferences en Document
document properties common en Document properties
document title: common en Document title:
documentation common en Documentation
documents common en Documents
doesn't matter common en Doesn't matter
domain common en Domain
domain name for mail-address, eg. "%1" common en Domain name for mail address, eg. "%1"
@ -491,6 +493,7 @@ el salvador common en EL SALVADOR
element role title common en Element role title
email common en Email
email-address of the user, eg. "%1" common en Email address of the user, eg. "%1"
emails common en Emails
embeded css styles, eg. '.red { background: red; }' (note the '.' before the class-name) or '@import url(...)' (class names are global for the whole page!) common en Embedded CSS styles, e.g. '.red { background: red; }' (note the '.' before the class-name) or '@import url(...)' (class names are global for the whole page!)
empty file common en Empty file
enable javascript onchange submit common en Enable JavaScript onchange submit
@ -701,6 +704,7 @@ if you use "2-factor-authentication", please enter the code here. common en If y
image common en Image
image directory relative to document root (use / !), example: common en Image folder relative to document root (use / !), example:
image url common en Image URL
images common en Images
import common en Import
import an etemplate from a xml-file common en Import an eTemplate from a XML-file
import table-definitions from existing db-table common en Import table definitions from existing DB table
@ -812,6 +816,7 @@ link is appended to mail allowing recipients to download or modify up to date ve
link is appended to mail allowing recipients to download up to date version of files common en Link is appended to mail allowing recipients to download up to date version of files
link target %1 not found! common en Link target %1 not found!
link title of current record common en Link title of current record
link to each entry common en Link to each entry
linkapps common en Link apps
linked common en Linked
linkentry common en Link entry
@ -867,6 +872,10 @@ maybe common en Maybe
mayotte common en MAYOTTE
medium common en Medium
menu common en Menu
merge common en Merge
merge & send common en Merge & send
merge individually common en Merge individually
merge options common en Merge options
merged document filename preferences en Merged document filename
message common en Message
message ... common en Message ...
@ -1185,6 +1194,7 @@ savant2 version differs from savant2 wrapper. <br/>this version: %1 <br/>savants
save common en Save
save all common en Save all
save as common en Save as
save as PDF common en Save as PDF
save as zip common en Save as ZIP
save selected columns as default preference for all users. common en Save columns as default preference for all users.
save the changes made and close the window common en Save changes and close
@ -1265,6 +1275,7 @@ select work email address common en Select work email address
select year common en Select year
selectbox common en Select box
selection common en Selection
selection of files can only be done in one folder. %1 files unselected. common en Selection of files can only be done in one folder. %1 files unselected.
send common en Send
send succeeded to %1 common en Send succeeded to %1
senegal common en SENEGAL
@ -1343,6 +1354,7 @@ spain common en SPAIN
span common en Span
span, class common en Span, Class
special characters common en special characters
spreadsheets common en Spreadsheets
sri lanka common en SRI LANKA
stack common en Stack
start a new search, cancel this link common en Start new search, cancel this link
@ -1504,6 +1516,7 @@ version-number, should be in the form: major.minor.revision.number (eg. 0.9.13.0
vertical alignment of row common en Vertical alignment of row
vfs upload directory common en VFS upload folder
video tutorials common en Video Tutorials
videos common en Videos
viet nam common en VIET NAM
view common en View
view linked %1 entries common en View linked %1 entries

View File

@ -370,6 +370,11 @@ class JsBase
$target = $value;
}
}
// if we unset fields stored directly in the database, they will NOT be updated :(
elseif (!$n)
{
$target = null;
}
else
{
unset($parent[$part]);

View File

@ -31,7 +31,8 @@ class Merge extends Api\Storage\Merge
var $public_functions = array(
'download_by_request' => true,
'show_replacements' => true,
"merge_entries" => true
'merge_entries' => true,
'ajax_merge_multiple' => true,
);
/**

View File

@ -184,6 +184,11 @@ class DateTime extends \DateTime
}
}
public function __toString()
{
return (string)$this->format(self::DATABASE);
}
/**
* Like DateTime::add, but additional allow to use a string run through DateInterval::createFromDateString
*

View File

@ -7002,9 +7002,10 @@ class Mail
* @param string&|false $_folder (passed by reference) will set the folder used. must be set with a folder, but will hold modifications if
* folder is modified. Set to false to not keep the message.
* @param string& $importID ID for the imported message, used by attachments to identify them unambiguously
* @param string[] $attachments Files attached to the email - the same files for every email
* @return mixed array of messages with success and failed messages or exception
*/
function importMessageToMergeAndSend(Storage\Merge $bo_merge, $document, $SendAndMergeTocontacts, &$_folder, &$importID='')
function importMessageToMergeAndSend(Storage\Merge $bo_merge, $document, $SendAndMergeTocontacts, &$_folder, &$importID = '', $attachments = [])
{
$importfailed = false;
$processStats = array('success'=>array(),'failed'=>array());
@ -7075,6 +7076,10 @@ class Mail
$mailObject->addReplyTo(Horde_Idna::encode($activeMailProfile['ident_email']),Mail::generateIdentityString($activeMailProfile,false));
}
foreach($attachments as $file)
{
$mailObject->addAttachment($file);
}
if(count($SendAndMergeTocontacts) > 1)
{
foreach(Mailer::$type2header as $type => $h)

View File

@ -224,7 +224,12 @@ class Credentials
{
continue;
}
$password = self::decrypt($row);
try {
$password = self::decrypt($row);
}
catch (NoSessionPassword $e) {
$password = self::UNAVAILABLE;
}
// Remove special x char added to the end for \0 trimming escape.
if ($type == self::SMIME && substr($password, -1) === 'x') $password = substr($password, 0, -1);
@ -533,7 +538,12 @@ class Credentials
if (empty($key))
{
if ($account_id > 0 && $account_id == $GLOBALS['egw_info']['user']['account_id'] &&
($key = Api\Cache::getSession('phpgwapi', 'password')))
($key = Api\Cache::getSession('phpgwapi', 'password')) &&
// do NOT encrypt password if (optional) SAML or OpenIdConnect auth is enabled
!array_filter(array_keys(Api\Config::read('phpgwapi')), static function($name)
{
return str_ends_with($name, '_discovery');
}))
{
$pw_enc = self::USER_AES;
$key = base64_decode($key);
@ -687,7 +697,7 @@ class Credentials
$session_key = Api\Cache::getSession('phpgwapi', 'password');
if (empty($session_key))
{
throw new Api\Exception\AssertionFailed("No session password available!");
throw new NoSessionPassword();
}
$key = base64_decode($session_key);
}
@ -921,4 +931,15 @@ class Credentials
{
return isset($GLOBALS['egw_setup']) ? $GLOBALS['egw_setup']->db : $GLOBALS['egw']->db;
}
}
/**
* Exception thrown if session has NO user password stored e.g. SingleSignOn via Saml or OpenIdConnect
*/
class NoSessionPassword extends Api\Exception\AssertionFailed
{
public function __construct(?string $msg=null, $code=100, ?\Throwable $previous=null)
{
parent::__construct($msg ?: "No session password available!", $code, $previous);
}
}

View File

@ -115,6 +115,25 @@ class MimeMagic
return $key;
}
/**
* Convert a MIME type to all known file extensions
*
* If we cannot map the type to any file extension, we return an empty array.
*
* @param string $_type The MIME type to be mapped to a file extension.
* @return array with possible file extensions
*/
public static function mime2extensions($_type)
{
$type = strtolower($_type);
if (isset(self::$mime_alias_map[$type])) $type = self::$mime_alias_map[$type];
return array_keys(array_filter(self::$mime_extension_map, static function($mime) use ($type)
{
return $mime == $type;
}));
}
/**
* Fix old / aliased mime-types by returning valid/default mime-type
*

View File

@ -15,6 +15,7 @@ namespace EGroupware\Api\Storage;
use DOMDocument;
use EGroupware\Api;
use EGroupware\Api\Mail;
use EGroupware\Api\Vfs;
use EGroupware\Collabora\Conversion;
use EGroupware\Stylite;
@ -2108,7 +2109,7 @@ abstract class Merge
try
{
$_folder = $this->keep_emails ? '' : FALSE;
$msgs = $mail_bo->importMessageToMergeAndSend($this, $content_url, $ids, $_folder);
$msgs = $mail_bo->importMessageToMergeAndSend($this, $content_url, $ids, $_folder, $import_id);
}
catch (Api\Exception\WrongUserinput $e)
{
@ -2484,12 +2485,17 @@ abstract class Merge
*
* @param string[]|null $ids Allows extending classes to process IDs in their own way. Leave null to pull from request.
* @param Merge|null $document_merge Already instantiated Merge object to do the merge.
* @param boolean|null $pdf Convert result to PDF
* @param Array options
* @param boolean options[individual] Instead of merging all entries into the file, merge each entry into its own file
* @param boolean options[pdf] Convert result to PDF
* @param boolean options[link] Link generated file to the entry
* @param boolean $return Return the path of the generated document instead of opening or downloading
* @throws Api\Exception
* @throws Api\Exception\AssertionFailed
*/
public static function merge_entries(array $ids = null, Merge &$document_merge = null, $pdf = null)
public static function merge_entries(array $ids = null, Merge &$document_merge = null, $options = [], bool $return = null)
{
// Setup & get what we need
if(is_null($document_merge) && class_exists($_REQUEST['merge']) && is_subclass_of($_REQUEST['merge'], 'EGroupware\\Api\\Storage\\Merge'))
{
$document_merge = new $_REQUEST['merge']();
@ -2499,10 +2505,22 @@ abstract class Merge
$document_merge = new Api\Contacts\Merge();
}
if(($error = $document_merge->check_document($_REQUEST['document'], '')))
$documents = (array)$_REQUEST['document'];
// Check for an email
$email = null;
foreach($documents as $key => $d)
{
error_log(__METHOD__ . "({$_REQUEST['document']}) $error");
return;
if(Vfs::mime_content_type($d) == 'message/rfc822')
{
$email = $d;
unset($documents[$key]);
}
}
if($email)
{
$mail_bo = Api\Mail::getInstance();
$mail_bo->openConnection();
}
if(is_null(($ids)))
@ -2513,14 +2531,168 @@ abstract class Merge
{
$ids = self::get_all_ids($document_merge);
}
if(is_null($pdf))
foreach(['pdf', 'individual', 'link', 'download'] as $option)
{
$pdf = (boolean)$_REQUEST['pdf'];
$$option = is_null($options) || empty($options) ? (boolean)$_REQUEST['options'][$option] : (boolean)$options[$option];
}
$app = (is_null($options) ? $_REQUEST['options']['app'] : $options['app']) ?? $GLOBALS['egw_info']['flags']['currentapp'];
if(is_null($return))
{
$return = (boolean)$_REQUEST['return'];
}
$filename = $document_merge->get_filename($_REQUEST['document'], $ids);
$result = $document_merge->merge_file($_REQUEST['document'], $ids, $filename, '', $header);
$id_group = $individual ? $ids : [$ids];
$merged = $attach = [];
$target = '';
foreach($id_group as $ids)
{
foreach($documents as $document)
{
if($document != $email)
{
// Generate file
$target = $document_merge->merge_entries_into_document((array)$ids, $document);
}
// PDF conversion
if($pdf)
{
$converted = $document_merge->pdf_conversion($target);
$target = $converted;
}
$merged[] = $target;
$attach[] = Vfs::PREFIX . $target;
// Move to entry
if($link)
{
foreach((array)$ids as $id)
{
Api\Link::link($app, $id, Api\Link::VFS_APPNAME, Vfs::PREFIX . $target);
}
}
}
// One email per id group
if($email && $mail_bo)
{
// Trick merge into not trying to open in compose
$mail_ids = $ids;
if(is_string($mail_ids))
{
$mail_ids = [$mail_ids];
}
if(count((array)$mail_ids) == 1 && !$open_email)
{
$mail_ids[] = null;
}
try
{
// Special email handling so we can grab it and stick it where we want
$mail_folder = $document_merge->keep_emails ? (count($id_group) == 1 ? $mail_bo->getDraftFolder() : '') : FALSE;
$mail_id = '';
$msgs = $mail_bo->importMessageToMergeAndSend($document_merge, Api\Vfs::PREFIX . $email, $mail_ids, $mail_folder, $mail_id, $attach);
}
catch (\Exception $e)
{
throw new Api\Exception("Unable to send email", 100, $e);
}
// Save to VFS so we can link to entry
if($link || $download)
{
// Load message
$message = $mail_bo->getMessageRawBody($mail_id, '', $mail_folder);
if(!$message)
{
throw new Api\Exception\AssertionFailed("Unable to read merged email\n" . $mail_folder . "/$mail_id");
}
$filename = $document_merge->get_filename($email, (array)$ids);
if(!str_ends_with($filename, pathinfo($email, PATHINFO_EXTENSION)))
{
$filename .= '.' . pathinfo($email, PATHINFO_EXTENSION);
}
if($download)
{
$target = $document_merge->get_save_path($filename);
// Make sure we won't overwrite something already there
$target = Vfs::make_unique($target);
file_put_contents(Vfs::PREFIX . $target, $message);
$merged[] = $target;
}
if($link)
{
foreach((array)$ids as $id)
{
$target = Api\Link::vfs_path($app, $id, $filename);
// Make sure we won't overwrite something already there
$target = Vfs::make_unique($target);
file_put_contents(Vfs::PREFIX . $target, $message);
if(!$download)
{
$merged[] = $target;
}
}
}
}
$attach = [];
}
}
// Find out what to do with it - can't handle multiple documents directly
if($return || count($merged) > 1)
{
return $merged;
}
// Open email in compose?
if($email && count($id_group) == 1 && $mail_id && class_exists("mail_ui"))
{
$mail_uid = \mail_ui::generateRowID($mail_bo->profileID, $mail_folder, $mail_id);
$mail_popup = '';
$mail_info = Api\Link::edit('mail', $mail_uid, $mail_popup);
$mail_info['from'] = 'composefromdraft';
Api\Framework::popup(Api\Framework::link("/index.php", $mail_info), '_blank', $mail_popup);
return;
}
// Merge done, present to user
if($document_merge->get_editable_mimes()[Vfs::mime_content_type($target)] &&
!in_array(Vfs::mime_content_type($target), explode(',', $GLOBALS['egw_info']['user']['preferences']['filemanager']['collab_excluded_mimes'])))
{
\Egroupware\Api\Egw::redirect_link('/index.php', array(
'menuaction' => 'collabora.EGroupware\\Collabora\\Ui.editor',
'path' => $target
));
}
else
{
\Egroupware\Api\Egw::redirect_link(Vfs::download_url($target, true));
}
}
/**
* Merge the given IDs into the given document, saves to VFS, and returns the path
*
* @param array $ids
* @param $pdf
* @return string|void
* @throws Api\Exception\AssertionFailed
* @throws Api\Exception\NotFound
* @throws Api\Exception\WrongParameter
* @throws Vfs\Exception\ProtectedDirectory
*/
protected function merge_entries_into_document(array $ids = [], $document)
{
if(($error = $this->check_document($document, '')))
{
error_log(__METHOD__ . "({$_REQUEST['document']}) $error");
return;
}
$filename = $this->get_filename($document, $ids);
$result = $this->merge_file($document, $ids, $filename, '', $header);
if(!is_file($result) || !is_readable($result))
{
@ -2528,7 +2700,7 @@ abstract class Merge
}
// Put it into the vfs using user's preferred directory if writable,
// or expected home dir (/home/username) if not
$target = $document_merge->get_save_path($filename);
$target = $this->get_save_path($filename);
// Make sure we won't overwrite something already there
$target = Vfs::make_unique($target);
@ -2536,7 +2708,52 @@ abstract class Merge
copy($result, Vfs::PREFIX . $target);
unlink($result);
// Find out what to do with it
return $target;
}
/**
* Convert a file into PDF
*
* Removes the original file
*
* @param $path
* @return mixed|string Path to converted file
* @throws Api\Exception\AssertionFailed
* @throws Api\Exception\NotFound
* @throws Api\Exception\WrongParameter
* @throws Vfs\Exception\ProtectedDirectory
*/
protected function pdf_conversion($path)
{
$editable_mimes = $this->get_editable_mimes();
if($editable_mimes[Vfs::mime_content_type($path)])
{
$error = '';
$converted_path = '';
$convert = new Conversion();
$convert->convert($path, $converted_path, 'pdf', $error);
if($error)
{
error_log(__METHOD__ . "({$_REQUEST['document']}) $path => $converted_path Error in PDF conversion: $error");
}
else
{
// Remove original
Vfs::unlink($path);
$path = $converted_path;
}
}
return $path;
}
/**
* Get a list of editable mime types
*
* @return array|String[]
*/
protected function get_editable_mimes()
{
$editable_mimes = array();
try
{
@ -2554,38 +2771,63 @@ abstract class Merge
// ignore failed discovery
unset($e);
}
return $editable_mimes;
}
// PDF conversion
if($editable_mimes[Vfs::mime_content_type($target)] && $pdf)
/**
* Merge one or more entries into one or more documents.
*
* @param array|null $ids
* @param \EGroupware\Api\Contacts\Merge|null $document_merge
* @param $documents
* @param $options
* @return string[] location(s) of merged files
*/
public static function ajax_merge_multiple(array $ids = [], $documents = [], $options = [])
{
$response = Api\Json\Response::get();
$_REQUEST['document'] = $documents;
$app = $options['app'] ?? $GLOBALS['egw_info']['flags']['currentapp'];
$message = implode(', ', Api\Link::titles($app, $ids)) . ":\n";
$return = true;
$open_email = $options['open_email'];
try
{
$error = '';
$converted_path = '';
$convert = new Conversion();
$convert->convert($target, $converted_path, 'pdf', $error);
$merge_result = static::merge_entries($ids, $document_merge, $options, !$open_email);
}
catch (\Exception $e)
{
$response->error($message . $e->getMessage());
}
if($error)
foreach($merge_result as $result)
{
if(is_string($result))
{
error_log(__METHOD__ . "({$_REQUEST['document']}) $target => $converted_path Error in PDF conversion: $error");
if($options['download'])
{
$response->apply('egw.open_link', [Vfs::download_url($result, true), '_browser']);
}
$message .= $result . "\n";
}
else
{
// Remove original
Vfs::unlink($target);
$target = $converted_path;
if($result['failed'])
{
$response->error($message . join(", ", $result['failed']));
}
else
{
if($result['success'])
{
$message .= join(", ", $result['success']);
}
}
}
}
if($editable_mimes[Vfs::mime_content_type($target)] &&
!in_array(Vfs::mime_content_type($target), explode(',', $GLOBALS['egw_info']['user']['preferences']['filemanager']['collab_excluded_mimes'])))
{
\Egroupware\Api\Egw::redirect_link('/index.php', array(
'menuaction' => 'collabora.EGroupware\\Collabora\\Ui.editor',
'path' => $target
));
}
else
{
\Egroupware\Api\Egw::redirect_link(Vfs::download_url($target, true));
}
$response->data($message);
}
/**
@ -3054,7 +3296,7 @@ abstract class Merge
'type' => 'taglist',
'label' => 'Merged document filename',
'name' => self::PREF_DOCUMENT_FILENAME,
'values' => self::DOCUMENT_FILENAME_OPTIONS,
'values' => static::DOCUMENT_FILENAME_OPTIONS,
'help' => 'Choose the default filename for merged documents.',
'xmlrpc' => True,
'admin' => False,

View File

@ -2770,6 +2770,7 @@ table.egwGridView_outer thead tr th.noResize:hover {
}
.long_task .message {
white-space: break-spaces;
height: inherit;
display: list-item;
border: none;

View File

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path d="m14 18h4v3h-4zm2 4h1l2 7-3 3-3-3 2-7zm8-14a8 8 0 0 1-8 8 8 8 0 0 1-8-8 8 8 0 0 1 8-8 8 8 0 0 1 8 8zm-4.4863 9.166c1.7e-4 1.2169 0.20298 5.982-1.0059 5.998 0.47857 1.5808 1.2001 3.7568 1.7578 4.7051 4.7904-0.2735 8.7344-0.88476 8.7344-0.88476l-3.25-8.1035s-2.7953-1.1474-6.2363-1.7148zm-7.0273 0.0234c-3.3306 0.57539-6.2363 1.6914-6.2363 1.6914l-3.25 8.1035s3.9208 0.6139 8.7344 0.88671c0.55783-0.94744 1.279-3.1254 1.7578-4.707-1.203-0.016-1.0079-4.7296-1.0059-5.9746z" fill="#62686a"/>
<path d="m25.143 9.1428a9.1429 9.1429 0 0 1-9.1429 9.1429 9.1429 9.1429 0 0 1-9.1429-9.1429 9.1429 9.1429 0 0 1 9.1429-9.1428 9.1429 9.1429 0 0 1 9.1429 9.1428zm4.7846 11.909m0.9297 9.7882-3.7148-9.2607s-5.6268-2.3162-11.142-2.3162c-5.1235 0-11.142 2.3162-11.142 2.3162l-3.7148 9.2607s7.3952 1.1601 14.857 1.1601c7.3932 0 14.857-1.1601 14.857-1.1601z" fill="#62686a"/>
</svg>

Before

Width:  |  Height:  |  Size: 641 B

After

Width:  |  Height:  |  Size: 514 B

View File

@ -128,7 +128,8 @@ class calendar_bo
MCAL_RECUR_WEEKLY => 'Weekly',
MCAL_RECUR_MONTHLY_WDAY => 'Monthly (by day)',
MCAL_RECUR_MONTHLY_MDAY => 'Monthly (by date)',
MCAL_RECUR_YEARLY => 'Yearly'
MCAL_RECUR_YEARLY => 'Yearly',
MCAL_RECUR_RDATE/*calendar_rrule::PERIOD*/ => 'Explicit dates',
);
/**
* @var array recur_days translates MCAL recur-days to verbose labels
@ -946,7 +947,7 @@ class calendar_bo
foreach($events as $event)
{
// PERIOD
$is_exception = $event['recur_type'] != calendar_rrule::PERIOD && in_array(Api\DateTime::to($event['start'], true), $exceptions);
$is_exception = in_array(Api\DateTime::to($event['start'], true), $exceptions);
$start = $this->date2ts($event['start'],true);
if ($event['whole_day'])
{
@ -1024,10 +1025,11 @@ class calendar_bo
$event[$ts] = $this->date2usertime((int)$event[$ts],$date_format);
}
}
// same with the recur exceptions
if (isset($event['recur_exception']) && is_array($event['recur_exception']))
// same with the recur exceptions and rdates
foreach(['recur_exception', 'recur_rdates'] as $name)
{
foreach($event['recur_exception'] as &$date)
if (!is_array($event[$name] ?? null)) continue;
foreach($event[$name] as &$date)
{
if ($event['whole_day'] && $date_format != 'server')
{
@ -1139,7 +1141,6 @@ class calendar_bo
* @param mixed $start start-date
* @param mixed $end end-date
* @param array $events where the repetions get inserted
* @param array $recur_exceptions with date (in Ymd) as key (and True as values), seems not to be used anymore
*/
function insert_all_recurrences($event,$_start,$end,&$events)
{
@ -1165,20 +1166,15 @@ class calendar_bo
new Api\DateTime($event['recur_enddate'], calendar_timezones::DateTimeZone($event['tzid']));
// unset exceptions, as we need to add them as recurrence too, but marked as exception
// (Period needs them though)
if($event['recur_type'] != calendar_rrule::PERIOD)
{
unset($event['recur_exception']);
}
unset($event['recur_exception']);
// loop over all recurrences and insert them, if they are after $start
$rrule = calendar_rrule::event2rrule($event, !$event['whole_day'], // true = we operate in usertime, like the rest of calendar_bo
// For whole day events, just stay in server time
$event['whole_day'] ? Api\DateTime::$server_timezone->getName() : Api\DateTime::$user_timezone->getName()
);
if($event['recur_type'] == calendar_rrule::PERIOD)
{
unset($event['recur_exception']);
}
unset($event['recur_rdates']);
$event['recur_type'] = MCAL_RECUR_NONE;
foreach($rrule as $time)
{
// $time is in timezone of event, convert it to usertime used here
@ -1194,7 +1190,7 @@ class calendar_bo
if (($ts = $this->date2ts($time)) < $start-$event_length)
{
//echo "<p>".$time." --> ignored as $ts < $start-$event_length</p>\n";
continue; // to early or original event (returned by interator too)
continue; // to early or original event (returned by iterator too)
}
$ts_end = $ts + $event_length;
@ -1230,7 +1226,7 @@ class calendar_bo
}
/**
* Adds one repetion of $event for $date_ymd to the $events array, after adjusting its start- and end-time
* Adds one repetition of $event for $date_ymd to the $events array, after adjusting its start- and end-time
*
* @param array $events array in which the event gets inserted
* @param array $event event to insert, it has start- and end-date of the first recurrence, not of $date_ymd

View File

@ -1404,7 +1404,7 @@ class calendar_boupdate extends calendar_bo
$this->check_reset_statuses($event, $old_event);
// set recur-enddate/range-end to real end-date of last recurrence
if (!empty($event['recur_type']) && $event['recur_enddate'] && $event['start'])
if (!empty($event['recur_type']) && (!empty($event['recur_enddate']) || $event['recur_type'] == calendar_rrule::PERIOD) && $event['start'])
{
$event['recur_enddate'] = new Api\DateTime($event['recur_enddate'], calendar_timezones::DateTimeZone($event['tzid']));
$event['recur_enddate']->setTime(23,59,59);
@ -1417,8 +1417,11 @@ class calendar_boupdate extends calendar_bo
$occurrence = $rrule->current();
}
while ($rrule->validDate($event['whole_day']) && ($enddate = $occurrence));
$enddate->modify(($event['end'] - $event['start']).' second');
$event['recur_enddate'] = $save_event['recur_enddate'] = $enddate->format('ts');
if ($enddate)
{
$enddate->modify(($event['end'] - $event['start']).' second');
$event['recur_enddate'] = $enddate->format('ts');
}
//error_log(__METHOD__."($event[title]) start=".Api\DateTime::to($event['start'],'string').', end='.Api\DateTime::to($event['end'],'string').', range_end='.Api\DateTime::to($event['recur_enddate'],'string'));
}
@ -1485,10 +1488,11 @@ class calendar_boupdate extends calendar_bo
{
$event['tz_id'] = calendar_timezones::tz2id($event['tzid'] = Api\DateTime::$user_timezone->getName());
}
// same with the recur exceptions
if (isset($event['recur_exception']) && is_array($event['recur_exception']))
// same with the recur exceptions and rdates
foreach(['recur_exception', 'recur_rdates'] as $name)
{
foreach($event['recur_exception'] as &$date)
if (!is_array($event[$name] ?? null)) continue;
foreach($event[$name] as &$date)
{
if ($event['whole_day'])
{
@ -2400,9 +2404,9 @@ class calendar_boupdate extends calendar_bo
*
* @param array $event the vCalendar data we try to find
* @param string filter='exact' exact -> find the matching entry
* check -> check (consitency) for identical matches
* check -> check (consistency) for identical matches
* relax -> be more tolerant
* master -> try to find a releated series master
* master -> try to find a related series master
* @return array calendar_ids of matching entries
*/
function find_event($event, $filter='exact')
@ -3120,12 +3124,12 @@ class calendar_boupdate extends calendar_bo
// we convert here from server-time to timestamps in user-time!
if (isset($event[$ts])) $event[$ts] = $event[$ts] ? $this->date2usertime($event[$ts]) : 0;
}
// same with the recur exceptions
if (isset($event['recur_exception']) && is_array($event['recur_exception']))
// same with the recur exceptions and rdates
foreach(['recur_exception', 'recur_rdates'] as $name)
{
foreach($event['recur_exception'] as $n => $date)
foreach($event[$name] ?? [] as $n => $date)
{
$event['recur_exception'][$n] = $this->date2usertime($date);
$event[$name][$n] = $this->date2usertime($date);
}
}
// same with the alarms
@ -3137,6 +3141,7 @@ class calendar_boupdate extends calendar_bo
}
}
}
/**
* Delete events that are more than $age years old
*

View File

@ -1303,40 +1303,42 @@ class calendar_ical extends calendar_boupdate
{
$event['id'] = $event_info['stored_event']['id']; // CalDAV does only provide UIDs
}
if (is_array($event['participants']))
// if file contains no participants add current user
if (empty($event['participants']))
{
// if the client does not return a status, we restore the original one
foreach ($event['participants'] as $uid => $status)
$event['participants'] = [$user => calendar_so::combine_status('A')];
}
// if the client does not return a status, we restore the original one
foreach ($event['participants'] as $uid => $status)
{
// Work around problems with Outlook CalDAV Synchronizer (https://caldavsynchronizer.org/)
// - always sends all participants back with status NEEDS-ACTION --> resets status of all participant, if user has edit rights
// --> allow only updates with other status then NEEDS-ACTION and therefore allow accepting or denying meeting requests for the user himself
if ($status[0] === 'X' || calendar_groupdav::get_agent() === 'caldavsynchronizer' && $status[0] === 'U')
{
// Work around problems with Outlook CalDAV Synchronizer (https://caldavsynchronizer.org/)
// - always sends all participants back with status NEEDS-ACTION --> resets status of all participant, if user has edit rights
// --> allow only updates with other status then NEEDS-ACTION and therefore allow accepting or denying meeting requests for the user himself
if ($status[0] === 'X' || calendar_groupdav::get_agent() === 'caldavsynchronizer' && $status[0] === 'U')
if (isset($event_info['stored_event']['participants'][$uid]))
{
if (isset($event_info['stored_event']['participants'][$uid]))
if ($this->log)
{
if ($this->log)
{
error_log(__FILE__.'['.__LINE__.'] '.__METHOD__.
"() Restore status for $uid\n",3,$this->logfile);
}
$event['participants'][$uid] = $event_info['stored_event']['participants'][$uid];
}
else
{
$event['participants'][$uid] = calendar_so::combine_status('U');
error_log(__FILE__.'['.__LINE__.'] '.__METHOD__.
"() Restore status for $uid\n",3,$this->logfile);
}
$event['participants'][$uid] = $event_info['stored_event']['participants'][$uid];
}
// restore resource-quantity from existing event as neither iOS nor Thunderbird returns our X-EGROUPWARE-QUANTITY
elseif ($uid[0] === 'r' && isset($event_info['stored_event']['participants'][$uid]))
else
{
$quantity = $role = $old_quantity = null;
calendar_so::split_status($status, $quantity, $role);
calendar_so::split_status($event_info['stored_event']['participants'][$uid], $old_quantity);
if ($old_quantity > 1)
{
$event['participants'][$uid] = calendar_so::combine_status('U', $old_quantity, $role);
}
$event['participants'][$uid] = calendar_so::combine_status('U');
}
}
// restore resource-quantity from existing event as neither iOS nor Thunderbird returns our X-EGROUPWARE-QUANTITY
elseif ($uid[0] === 'r' && isset($event_info['stored_event']['participants'][$uid]))
{
$quantity = $role = $old_quantity = null;
calendar_so::split_status($status, $quantity, $role);
calendar_so::split_status($event_info['stored_event']['participants'][$uid], $old_quantity);
if ($old_quantity > 1)
{
$event['participants'][$uid] = calendar_so::combine_status('U', $old_quantity, $role);
}
}
}
@ -1673,7 +1675,7 @@ class calendar_ical extends calendar_boupdate
unset($event['id']);
unset($event_info['stored_event']);
$event['recur_type'] = MCAL_RECUR_NONE;
if (empty($event['recurrence']))
if (empty($event['recurrence']) && $event_info['master_event'])
{
// find an existing exception slot
$occurence = $exception = false;
@ -2765,10 +2767,10 @@ class calendar_ical extends calendar_boupdate
if($attributes['params']['VALUE'] == 'PERIOD')
{
$vcardData['recur_type'] = calendar_rrule::PERIOD;
$vcardData['recur_exception'] = [];
$vcardData['recur_rdates'] = [];
foreach($attributes['values'] as $date)
{
$vcardData['recur_exception'][] = mktime(
$vcardData['recur_rdates'][] = mktime(
$hour,
$minutes,
$seconds,
@ -3185,7 +3187,7 @@ class calendar_ical extends calendar_boupdate
$event['priority'] = 2; // default
$event['alarm'] = $alarms;
// now that we know what the vard provides,
// now that we know what the ical provides,
// we merge that data with the information we have about the device
while (($fieldName = array_shift($supportedFields)))
{
@ -3196,6 +3198,7 @@ class calendar_ical extends calendar_boupdate
case 'recur_data':
case 'recur_exception':
case 'recur_count':
case 'recur_rdates':
case 'whole_day':
// not handled here
break;
@ -3205,7 +3208,7 @@ class calendar_ical extends calendar_boupdate
if ($event['recur_type'] != MCAL_RECUR_NONE)
{
$event['reference'] = 0;
foreach (array('recur_interval','recur_enddate','recur_data','recur_exception','recur_count') as $r)
foreach (array('recur_interval','recur_enddate','recur_data','recur_exception','recur_count','recur_rdates') as $r)
{
if (isset($vcardData[$r]))
{

View File

@ -13,6 +13,7 @@
*/
use EGroupware\Api;
use EGroupware\Api\Storage\Merge;
/**
* Calendar - document merge object
@ -129,7 +130,21 @@ class calendar_merge extends Api\Storage\Merge
return parent::merge_string($content, $ids, $err, $mimetype, $fix, $charset);
}
public static function merge_entries(array $ids = null, \EGroupware\Api\Storage\Merge &$document_merge = null, $pdf = null)
/**
* Merge the selected IDs into the given document, save it to the VFS, then
* either open it in the editor or have the browser download the file.
*
* @param string[]|null $ids Allows extending classes to process IDs in their own way. Leave null to pull from request.
* @param Merge|null $document_merge Already instantiated Merge object to do the merge.
* @param Array options
* @param boolean options[individual] Instead of merging all entries into the file, merge each entry into its own file
* @param boolean options[pdf] Convert result to PDF
* @param boolean options[link] Link generated file to the entry
* @param boolean $return Return the path of the generated document instead of opening or downloading
* @throws Api\Exception
* @throws Api\Exception\AssertionFailed
*/
public static function merge_entries(array $ids = null, Merge &$document_merge = null, $options = [], bool $return = null)
{
$document_merge = new calendar_merge();
@ -138,72 +153,75 @@ class calendar_merge extends Api\Storage\Merge
$ids = json_decode($_REQUEST['id'], true);
}
// Try to make time span into appropriate ranges to match
$template = $ids['view'] ?: '';
if(stripos($_REQUEST['document'], 'month') !== false || stripos($_REQUEST['document'], lang('month')) !== false)
foreach($_REQUEST['document'] as &$document)
{
$template = 'month';
}
if(stripos($_REQUEST['document'], 'week') !== false || stripos($_REQUEST['document'], lang('week')) !== false)
{
$template = 'week';
}
// Try to make time span into appropriate ranges to match
$template = $ids['view'] ?: '';
if(stripos($document, 'month') !== false || stripos($document, lang('month')) !== false)
{
$template = 'month';
}
if(stripos($document, 'week') !== false || stripos($document, lang('week')) !== false)
{
$template = 'week';
}
//error_log("Detected template $template");
$date = $ids['date'];
$first = $ids['first'];
$last = $ids['last'];
//error_log("Detected template $template");
$date = $ids['date'];
$first = $ids['first'];
$last = $ids['last'];
// Pull dates from session if they're not in the request
if(!array_key_exists('first', $ids))
{
$ui = new calendar_ui();
$date = $ui->date;
$first = $ui->first;
$last = $ui->last;
}
switch($template)
{
case 'month':
// Trim to _only_ the month, do not pad to week start / end
$time = new Api\DateTime($date);
$timespan = array(array(
'start' => Api\DateTime::to($time->format('Y-m-01 00:00:00'), 'ts'),
'end' => Api\DateTime::to($time->format('Y-m-t 23:59:59'), 'ts')
));
break;
case 'week':
$timespan = array();
$start = new Api\DateTime($first);
$end = new Api\DateTime($last);
$t = clone $start;
$t->modify('+1 week')->modify('-1 second');
if($t < $end)
{
do
{
$timespan[] = array(
'start' => $start->format('ts'),
'end' => $t->format('ts')
);
$start->modify('+1 week');
$t->modify('+1 week');
}
while($start < $end);
// Pull dates from session if they're not in the request
if(!array_key_exists('first', $ids))
{
$ui = new calendar_ui();
$date = $ui->date;
$first = $ui->first;
$last = $ui->last;
}
switch($template)
{
case 'month':
// Trim to _only_ the month, do not pad to week start / end
$time = new Api\DateTime($date);
$timespan = array(array(
'start' => Api\DateTime::to($time->format('Y-m-01 00:00:00'), 'ts'),
'end' => Api\DateTime::to($time->format('Y-m-t 23:59:59'), 'ts')
));
break;
}
// Fall through
default:
$timespan = array(array(
'start' => $first,
'end' => $last
));
case 'week':
$timespan = array();
$start = new Api\DateTime($first);
$end = new Api\DateTime($last);
$t = clone $start;
$t->modify('+1 week')->modify('-1 second');
if($t < $end)
{
do
{
$timespan[] = array(
'start' => $start->format('ts'),
'end' => $t->format('ts')
);
$start->modify('+1 week');
$t->modify('+1 week');
}
while($start < $end);
break;
}
// Fall through
default:
$timespan = array(array(
'start' => $first,
'end' => $last
));
}
// Add path into document
static::check_document($document, $GLOBALS['egw_info']['user']['preferences']['calendar']['document_dir']);
}
// Add path into document
static::check_document($_REQUEST['document'], $GLOBALS['egw_info']['user']['preferences']['calendar']['document_dir']);
return \EGroupware\Api\Storage\Merge::merge_entries(array_key_exists('0', $ids) ? $ids : $timespan, $document_merge);
return parent::merge_entries(array_key_exists('0', $ids) ? $ids : $timespan, $document_merge, $options, $return);
}
public function get_filename_placeholders($document, $ids)
@ -1238,4 +1256,4 @@ class calendar_merge extends Api\Storage\Merge
}
return $placeholders;
}
}
}

View File

@ -45,29 +45,28 @@ class calendar_rrule implements Iterator
*/
const WEEKLY = 2;
/**
* Monthly recurrance iCal: monthly_bymonthday
* Monthly recurrence iCal: monthly_bymonthday
*/
const MONTHLY_MDAY = 3;
/**
* Monthly recurrance iCal: BYDAY (by weekday, eg. 1st Friday of month)
* Monthly recurrence iCal: BYDAY (by weekday, eg. 1st Friday of month)
*/
const MONTHLY_WDAY = 4;
/**
* Yearly recurrance
* Yearly recurrence
*/
const YEARLY = 5;
/**
* Hourly recurrance
* Hourly recurrence
*/
const HOURLY = 8;
/**
* Minutely recurrance
* Minutely recurrence
*/
const MINUTELY = 7;
/**
* By date or period
* (a list of dates)
* RDATE: date or period (a list of dates, instead of a RRULE)
*/
const PERIOD = 9;
@ -226,9 +225,9 @@ class calendar_rrule implements Iterator
public $time;
/**
* Current "position" / time
* Current "position" / time or null, if invalid (out of explicit RDATEs)
*
* @var Api\DateTime
* @var Api\DateTime|null
*/
public $current;
@ -257,9 +256,10 @@ class calendar_rrule implements Iterator
* @param int $interval =1 1, 2, ...
* @param DateTime $enddate =null enddate or null for no enddate (in which case we user '+5 year' on $time)
* @param int $weekdays =0 self::SUNDAY=1|self::MONDAY=2|...|self::SATURDAY=64
* @param array $exceptions =null DateTime objects with exceptions
* @param DateTime[] $exceptions =null DateTime objects with exceptions
* @param DateTime[] $rdates =null DateTime objects with rdates (for type self::PERIOD)
*/
public function __construct(DateTime $time,$type,$interval=1,DateTime $enddate=null,$weekdays=0,array $exceptions=null)
public function __construct(DateTime $time,$type,$interval=1,DateTime $enddate=null,$weekdays=0,array $exceptions=null,array $rdates=null)
{
switch($GLOBALS['egw_info']['user']['preferences']['calendar']['weekdaystarts'])
{
@ -319,16 +319,15 @@ class calendar_rrule implements Iterator
$this->enddate = $enddate;
if($type == self::PERIOD)
{
foreach($exceptions as $exception)
foreach($rdates as $rdate)
{
$exception->setTimezone($this->time->getTimezone());
$this->period[] = $exception;
$rdate->setTimezone($this->time->getTimezone());
$this->period[] = $rdate;
}
$enddate = clone(count($this->period) ? end($this->period) : $this->time);
// Make sure to include the last date as valid
$enddate->modify('+1 second');
reset($this->period);
unset($exceptions);
}
// no recurrence --> current date is enddate
if ($type == self::NONE)
@ -376,6 +375,7 @@ class calendar_rrule implements Iterator
{
switch($type)
{
default:
case self::DAILY:
$duration = 24*3600;
break;
@ -413,11 +413,11 @@ class calendar_rrule implements Iterator
/**
* Return the current element
*
* @return Api\DateTime
* @return ?Api\DateTime
*/
public function current(): Api\DateTime
public function current(): ?Api\DateTime
{
return clone $this->current;
return $this->current ? clone $this->current : null;
}
/**
@ -427,7 +427,7 @@ class calendar_rrule implements Iterator
*/
public function key(): int
{
return (int)$this->current->format('Ymd');
return $this->current ? (int)$this->current->format('Ymd') : 0;
}
/**
@ -498,14 +498,15 @@ class calendar_rrule implements Iterator
$this->current->modify($this->interval.' minute');
break;
case self::PERIOD:
$index = array_search($this->current, $this->period);
$next = $this->enddate ?? new Api\DateTime();
if($index !== false && $index + 1 < count($this->period))
if (($next = next($this->period)))
{
$next = $this->period[$index + 1];
$this->current->setDate($next->format('Y'), $next->format('m'), $next->format('d'));
$this->current->setTime($next->format('H'), $next->format('i'), $next->format('s'), 0);
}
else
{
$this->current = null;
}
$this->current->setDate($next->format('Y'), $next->format('m'), $next->format('d'));
$this->current->setTime($next->format('H'), $next->format('i'), $next->format('s'), 0);
break;
default:
@ -522,7 +523,7 @@ class calendar_rrule implements Iterator
{
$this->next_no_exception();
}
while($this->exceptions && in_array($this->current->format('Ymd'),$this->exceptions));
while($this->current && $this->exceptions && in_array($this->current->format('Ymd'),$this->exceptions));
}
/**
@ -595,7 +596,14 @@ class calendar_rrule implements Iterator
*/
public function rewind(): void
{
$this->current = clone $this->time;
if ($this->type == self::PERIOD)
{
$this->current = $this->period ? clone reset($this->period) : null;
}
else
{
$this->current = clone $this->time;
}
while ($this->valid() &&
$this->exceptions &&
in_array($this->current->format('Ymd'),$this->exceptions))
@ -612,6 +620,10 @@ class calendar_rrule implements Iterator
*/
public function validDate(bool $use_just_date=null): bool
{
if (!$this->current)
{
return false;
}
if ($use_just_date)
{
return $this->current->format('Ymd') <= $this->enddate_ymd;
@ -626,7 +638,7 @@ class calendar_rrule implements Iterator
*/
public function valid(): bool
{
return $this->current->format('ts') < $this->enddate_ts;
return $this->current && $this->current->format('ts') < $this->enddate_ts;
}
/**
@ -853,10 +865,17 @@ class calendar_rrule implements Iterator
{
foreach($event['recur_exception'] as $exception)
{
$exceptions[] = is_a($exception,'DateTime') ? $exception : new Api\DateTime($exception,$timestamp_tz);
$exceptions[] = is_a($exception,'DateTime') ? $exception : new Api\DateTime($exception, $timestamp_tz);
}
}
return new calendar_rrule($time,$event['recur_type'],$event['recur_interval'],$enddate??null,$event['recur_data'],$exceptions??null);
if (is_array($event['recur_rdates']))
{
foreach($event['recur_rdates'] as $rdate)
{
$rdates[] = is_a($rdate,'DateTime') ? $rdate : new Api\DateTime($rdate, $timestamp_tz);
}
}
return new calendar_rrule($time,$event['recur_type'],$event['recur_interval'],$enddate??null,$event['recur_data'],$exceptions??null,$rdates??null);
}
/**
@ -955,6 +974,7 @@ class calendar_rrule implements Iterator
'recur_enddate' => $this->enddate ? $this->enddate->format('ts') : null,
'recur_data' => $this->weekdays,
'recur_exception' => $this->exceptions,
'recur_rdates' => $this->period,
);
}

View File

@ -43,6 +43,7 @@ if(!extension_loaded('mcal'))
define('MCAL_M_WEEKEND',65);
define('MCAL_M_ALLDAYS',127);
}
define('MCAL_RECUR_RDATE',9);
define('REJECTED',0);
define('NO_RESPONSE',1);
@ -311,7 +312,7 @@ class calendar_so
* All times (start, end and modified) are returned as timesstamps in servertime!
*
* @param int|array|string $ids id or array of id's of the entries to read, or string with a single uid
* @param int $recur_date =0 if set read the next recurrence at or after the timestamp, default 0 = read the initital one
* @param int $recur_date =0 if set read the next recurrence at or after the timestamp, default 0 = read the initial one
* @param boolean $read_recurrence =false true: read the exception, not the series master (only for recur_date && $ids='<uid>'!)
* @return array|boolean array with cal_id => event array pairs or false if entry not found
*/
@ -426,12 +427,21 @@ class calendar_so
}
if (!(int)$recur_date && !empty($event['recur_type']))
{
foreach($this->db->select($this->dates_table, 'cal_id,cal_start', array(
foreach($this->db->select($this->dates_table, 'cal_id,cal_start,recur_exception', [
'cal_id' => $ids,
]+($event['recur_type'] == MCAL_RECUR_RDATE ? [] : [
'recur_exception' => true,
), __LINE__, __FILE__, false, 'ORDER BY cal_id,cal_start', 'calendar') as $row)
]), __LINE__, __FILE__, false, 'ORDER BY cal_id,cal_start', 'calendar') as $row)
{
$events[$row['cal_id']]['recur_exception'][] = $row['cal_start'];
if ($row['recur_exception'])
{
$events[$row['cal_id']]['recur_exception'][] = $row['cal_start'];
}
// rdates are both, exceptions and regular dates!
if ($event['recur_type'] == MCAL_RECUR_RDATE)
{
$events[$row['cal_id']]['recur_rdates'][] = $row['cal_start'];
}
}
break; // as above select read all exceptions (and I dont think too short uid problem still exists)
}
@ -1129,7 +1139,7 @@ class calendar_so
{
$row['participants'] = array();
}
$row['recur_exception'] = $row['alarm'] = array();
$row['recur_exception'] = $row['recur_rdates'] = $row['alarm'] = array();
// compile a list of recurrences per cal_id
if (!isset($recur_ids[$row['cal_id']]) || !in_array($id, $recur_ids[$row['cal_id']])) $recur_ids[$row['cal_id']][] = $id;
@ -1178,9 +1188,8 @@ class calendar_so
// query recurrance exceptions, if needed: enum_recuring && !daywise is used in calendar_groupdav::get_series($uid,...)
if (!$params['enum_recuring'] || !$params['daywise'])
{
foreach($this->db->select($this->dates_table, 'cal_id,cal_start', array(
foreach($this->db->select($this->dates_table, 'cal_id,cal_start,recur_exception', array(
'cal_id' => $ids,
'recur_exception' => true,
), __LINE__, __FILE__, false, 'ORDER BY cal_id,cal_start', 'calendar') as $row)
{
// for enum_recurring events are not indexed by cal_id, but $cal_id.'-'.$cal_start
@ -1192,7 +1201,14 @@ class calendar_so
if ($event['id'] == $row['cal_id']) break;
}
}
$events[$id]['recur_exception'][] = $row['cal_start'];
if (Api\Db::from_bool($row['recur_exception']))
{
$events[$id]['recur_exception'][] = $row['cal_start'];
}
if ($events[$id]['recur_type'] == MCAL_RECUR_RDATE)
{
$events[$id]['recur_rdates'][] = $row['cal_start'];
}
}
}
//custom fields are not shown in the regular views, so we only query them, if explicitly required
@ -1496,7 +1512,7 @@ ORDER BY cal_user_type, cal_usre_id
if ($cal_id)
{
// query old recurrance information, before updating main table, where recur_endate is now stored
// query old recurrence information, before updating main table, where recur_endate is now stored
if (!empty($event['recur_type']))
{
$old_repeats = $this->db->select($this->repeats_table, "$this->repeats_table.*,range_end AS recur_enddate",
@ -1610,7 +1626,7 @@ ORDER BY cal_user_type, cal_usre_id
}
$event['recur_exception'] = is_array($event['recur_exception']) ? $event['recur_exception'] : array();
if (!empty($event['recur_exception']))
if (count($event['recur_exception']) > 1)
{
sort($event['recur_exception']);
}
@ -1707,7 +1723,7 @@ ORDER BY cal_user_type, cal_usre_id
// truncate recurrences by given exceptions
if (count($event['recur_exception']))
{
// added and existing exceptions: delete the execeptions from the user table, it could be the first time
// added and existing exceptions: delete the exceptions from the user table, it could be the first time
$this->db->delete($this->user_table,array('cal_id' => $cal_id,'cal_recur_date' => $event['recur_exception']),__LINE__,__FILE__,'calendar');
// update recur_exception flag based on current exceptions
$this->db->update($this->dates_table, 'recur_exception='.$this->db->expression($this->dates_table,array(

View File

@ -113,6 +113,11 @@ class calendar_timezones
self::$tz_cache[$id] = Api\Db::strip_array_keys($data,'tz_');
}
}
// check for a Windows timezone without "Standard Time" postfix
if (!isset($id) && strpos($tzid, '/') === false)
{
$id = self::tz2id($tzid.' Standard Time');
}
if (isset($id) && $what != 'id')
{
return self::id2tz($id,$what);

View File

@ -1263,7 +1263,7 @@ export class CalendarApp extends EgwApp
}
);
*/
if(typeof framework !== 'undefined' && framework.applications.calendar && framework.applications.calendar.tab)
if(typeof framework !== 'undefined' && framework.applications?.calendar && framework.applications.calendar.tab)
{
let swipe = new tapAndSwipe(framework.applications.calendar.tab.contentDiv, {
//Generic swipe handler for all directions
@ -2429,7 +2429,7 @@ export class CalendarApp extends EgwApp
{
// This activates calendar app if you call setState from a different app
// such as home. If we change state while not active, sizing is wrong.
if(typeof framework !== 'undefined' && framework.applications.calendar && framework.applications.calendar.hasSideboxMenuContent)
if(typeof framework !== 'undefined' && framework.applications?.calendar && framework.applications.calendar.hasSideboxMenuContent)
{
framework.setActiveApp(framework.applications.calendar);
}
@ -2658,7 +2658,7 @@ export class CalendarApp extends EgwApp
// Show loading div to hide redrawing
egw.loading_prompt(
this.appname,true,egw.lang('please wait...'),
typeof framework !== 'undefined' ? framework.applications.calendar.tab.contentDiv : false,
typeof framework !== 'undefined' ? framework.applications?.calendar?.tab?.contentDiv : false,
egwIsMobile()?'horizontal':'spinner'
);
@ -3483,7 +3483,7 @@ export class CalendarApp extends EgwApp
// Show ajax loader
if(typeof framework !== 'undefined')
{
framework.applications.calendar.sidemenuEntry.showAjaxLoader();
framework.applications?.calendar?.sidemenuEntry?.showAjaxLoader();
}
if(state.view === 'planner' && state.sortby === 'user')
@ -3580,7 +3580,7 @@ export class CalendarApp extends EgwApp
// Hide AJAX loader
else if(typeof framework !== 'undefined')
{
framework.applications.calendar.sidemenuEntry.hideAjaxLoader();
framework.applications?.calendar?.sidemenuEntry.hideAjaxLoader();
egw.loading_prompt('calendar', false)
}

View File

@ -247,6 +247,7 @@ exclude weekend calendar de Wochenende ausschließen
execute a further action for this entry calendar de Führt einen weiteren Befehl für diesen Eintrag aus
existing links calendar de Bestehende Verknüpfungen
exists calendar de Existiert
explicit dates calendar de Explizite Termine
export definition to use for nextmatch export calendar de Export Profil der Listenansicht (Disketten Symbol)
exports events from your calendar in ical format. calendar de Exportiert Termine im iCal-Format
exports events from your calendar into a csv file. calendar de Exportiert Termine im CSV-Format

View File

@ -247,6 +247,7 @@ exclude weekend calendar en Exclude Weekend
execute a further action for this entry calendar en Execute a further action for this entry
existing links calendar en Existing links
exists calendar en Exists
explicit dates calendar en Explicit dates
export definition to use for nextmatch export calendar en Export definition to use for nextmatch export
exports events from your calendar in ical format. calendar en Exports events from your calendar in iCal format.
exports events from your calendar into a csv file. calendar en Exports events from your calendar into a CSV file.

View File

@ -120,8 +120,20 @@
</row>
<row valign="top">
<et2-description for="recur_data" value="Repeat days"></et2-description>
<et2-select-dow statustext="Days of the week for a weekly repeated event" id="recur_data" rows="6"
<et2-vbox>
<et2-select-dow statustext="Days of the week for a weekly repeated event" id="recur_data" rows="6"
multiple="true" placeholder=""></et2-select-dow>
<grid id="recur_rdates" class="recur_rdates">
<columns>
<column/>
</columns>
<rows>
<row>
<et2-date-time id="$row" readonly="true"></et2-date-time>
</row>
</rows>
</grid>
</et2-vbox>
<et2-vbox>
<et2-description value="Exceptions"></et2-description>
<et2-button statustext="Create an exception for the given date" label="@exception_label"

View File

@ -12,7 +12,7 @@ Egroupware
<!DOCTYPE overlay PUBLIC "-//EGroupware GmbH//eTemplate 2.0//EN" "https://www.egroupware.org/etemplate2.0.dtd">
<overlay>
<template id="calendar.sidebox">
<template id="calendar.sidebox" slot="left">
<et2-vbox parentId="calendar-et2_target">
<calendar-date id="date"></calendar-date>
<et2-textbox type="hidden" id="first"></et2-textbox>

View File

@ -12,7 +12,7 @@ Egroupware
<!DOCTYPE overlay PUBLIC "-//EGroupware GmbH//eTemplate 2.0//EN" "https://www.egroupware.org/etemplate2.0.dtd">
<overlay>
<template id="calendar.todo" width="30%">
<template id="calendar.todo" width="30%" slot="right">
<et2-box class="calendar_calDayTodos">
<et2-box class="calendar_calDayTodosHeader" width="100%">
<et2-button align="left" statustext="Add" id="add" image="add" onclick="egw.open('','infolog','add',{action: 'new',type:'task'});"></et2-button>

View File

@ -12,7 +12,7 @@ Egroupware
<!DOCTYPE overlay PUBLIC "-//EGroupware GmbH//eTemplate 2.0//EN" "https://www.egroupware.org/etemplate2.0.dtd">
<overlay>
<template id="calendar.toolbar">
<template id="calendar.toolbar" slot="main-header">
<et2-button id="add" image="add" class="imageOnly" statustext="add new event" onclick="app.calendar.toolbar_action(widget);" noSubmit="true"></et2-button>
<et2-searchbox id="keywords" overlay="false" onchange="app.calendar.update_state({view: 'listview',search: widget.getValue()});return false;" placeholder="Search"></et2-searchbox>
<toolbar id="toolbar" width="100%" flat_list="false"/>

View File

@ -0,0 +1,24 @@
@import (less) "../default/app.css";
#calendar-view, #calendar-todo, #calendar-planner, #calendar-list {
position: relative;
width: 100% !important;
}
#calendar-sidebox {
width: 100%;
display: block !important;
iframe {
display: none;
}
}
#calendar-todo {
width: 100%;
height: calc(100% - 10px);
left: initial !important;
}
.calendar_calEvent:not([class*=" cat_"]) {
background-color: var(--primary-background-color)
}

View File

@ -2046,6 +2046,11 @@ div.calendar {
width: 100%;
line-height: 30px;
}
#calendar-edit #calendar-edit_calendar-edit-recurrence table.recur_rdates tbody {
display: table;
width: 100%;
line-height: normal;
}
#calendar-edit #calendar-edit_calendar-edit-custom tbody {
display: table;
width: 100%;

View File

@ -2034,6 +2034,11 @@ div.calendar {
width: 100%;
line-height: 30px;
}
#calendar-edit #calendar-edit_calendar-edit-recurrence table.recur_rdates tbody {
display: table;
width: 100%;
line-height: normal;
}
#calendar-edit #calendar-edit_calendar-edit-custom tbody {
display: table;
width: 100%;

View File

@ -489,6 +489,13 @@ div.calendar { position: relative; }
line-height: 30px;
}
}
#calendar-edit_calendar-edit-recurrence{
table.recur_rdates tbody {
display: table;
width: 100%;
line-height: normal;
}
}
/*###########################################*/
// Benutzerdefiniert

View File

@ -0,0 +1,187 @@
<?php
/**
* EGroupware - REST API client for PHP
*
* @link https://www.egroupware.org
* @license https://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
* @package api
* @subpackage caldav/rest
* @author Ralf Becker <rb-at-egroupware.org>
* @copyright (c) 2024 by Ralf Becker <rb-at-egroupware.org>
*/
/* Example usage of this client:
require_once('/path/to/egroupware/doc/api-client.php');
if (PHP_SAPI !== 'cli')
{
die('This script can only be run from the command line.');
}
$base_url = 'https://egw.example.org/egroupware/groupdav.php';
$authorization[parse_url($base_url, PHP_URL_HOST)] = 'Authorization: Basic '.base64_encode('sysop:secret');
$params = [
'filters[info_status]' => 'archive',
];
$courses = [];
foreach(apiIterator('/infolog/', $params) as $infolog)
{
echo json_encode($infolog, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT)."\n";
foreach($infolog['participants'] as $account_id => $participant)
{
if ($participant['roles']['owner'] ?? false)
{
echo json_encode($contact=api('/addressbook-accounts/'.$account_id),JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT)."\n";
break;
}
}
}
*/
/**
* Iterate through API calls on collections
*
* This function only queries a limited number of entries (default 100) and uses sync-token to query more.
*
* @param string $url either path (starting with / and prepending global $base_url) or full URL
* @param array& $params can contain optional "sync-token" (default="") and "nresults" (default=100) and returns final "sync-token"
* @return Generator<array> yields array with additional value for key "@self" containing the key of the responses-object yielded
* @throws JsonException|Exception see api
*/
function apiIterator(string $url, array &$params=[])
{
while(true)
{
if (!isset($params['nresults']))
{
$params['nresults'] = 100;
}
if (!isset($params['sync-token']))
{
$params['sync-token']='';
}
$responses = api($url, 'GET', $params);
if (!isset($responses['responses']))
{
throw new \Exception('Invalid respose: '.(is_scalar($responses) ? $responses : json_encode($responses)));
}
foreach($responses['responses'] as $self => $response)
{
$response['@self'] = $self;
yield $response;
}
$params['sync-token'] = $responses['sync-token'] ?? '';
if (empty($responses['more-results']))
{
return;
}
}
}
/**
* Make an API call to given URL
*
* Authorization is added from global $authorization array indexed by host-name of $url or $base_url
*
* @param string $url either path (starting with / and prepending global $base_url) or full URL
* @param string $method
* @param string|array|resource $body for GET&DELETE this is added as query and must not be a resource/file-handle
* @param array $header
* @param array|null $response_header associative array of response headers, key 0 has HTTP status
* @param int $follow how many redirects to follow, default 3, can be set to 0 to NOT follow
* @return array|string array of decoded JSON or string body
* @throws JsonException for invalid JSON
* @throws Exception with code=0: opening http connection, code=HTTP status, if status is NOT 2xx
*/
function api(string $url, string $method='GET', $body='', array $header=['Content-Type: application/json'], ?array &$response_header=null, int $follow=3)
{
global $base_url, $authorization;
if ($url[0] === '/')
{
$url = $base_url . $url;
}
if (in_array(strtoupper($method), ['GET', 'DELETE']) && $body && !is_resource($body))
{
$url .= '?' . (is_array($body) ? http_build_query($body) : $body);
}
if (!($curl = curl_init($url)))
{
throw new Exception(curl_error($curl));
}
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_HEADER, true);
if ($follow > 0)
{
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($curl, CURLOPT_MAXREDIRS, $follow);
}
switch (strtoupper($method))
{
case 'POST':
curl_setopt($curl, CURLOPT_POST, true);
break;
case 'PUT':
case 'DELETE':
case 'PATCH':
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, strtoupper($method));
break;
case 'GET':
curl_setopt($curl, CURLOPT_HTTPGET, true);
break;
}
$header = array_merge($header, ['User-Agent: '.basename(__FILE__, '.php'), $authorization[parse_url($url, PHP_URL_HOST)]]);
if (in_array(strtoupper($method), ['POST', 'PUT', 'PATCH']))
{
if (is_resource($body))
{
fseek($body, 0, SEEK_END);
curl_setopt($curl, CURLOPT_INFILESIZE, ftell($body));
fseek($body, 0);
}
curl_setopt($curl, is_resource($body) ? CURLOPT_INFILE : CURLOPT_POSTFIELDS, is_array($body) ? json_encode($body) : $body);
}
if (!array_filter($header, function($header)
{
return stripos($header, 'Accept:') === 0;
}))
{
$header[] = 'Accept: application/json';
}
curl_setopt($curl, CURLOPT_HTTPHEADER, $header);
$response_header = [];
if (($response = curl_exec($curl)) === false)
{
throw new Exception(curl_error($curl), 0);
}
do {
[$rheader, $response] = explode("\r\n\r\n", $response, 2);
foreach (explode("\r\n", $rheader) as $line)
{
list($key, $value) = explode(':', $line, 2) + [null, null];
if (!isset($value))
{
$response_header[0] = $key;
}
else
{
$response_header[strtolower($key)] = trim($value);
}
}
[, $http_status] = explode(' ', $response_header[0], 2);
}
while ($http_status[0] === '3' && $follow && preg_match('#^HTTP/[\d.]+ \d+#', $response));
if ($http_status[0] !== '2')
{
throw new Exception("Unexpected HTTP status code $http_status: $response", (int)$http_status);
}
if ($response !== '' && preg_match('#^application/([^+; ]+\+)?json(;|$)#', $response_header['content-type']))
{
return json_decode($response, true, 512, JSON_THROW_ON_ERROR);
}
return $response;
}

View File

@ -109,8 +109,8 @@ services:
# old API and eTemplate(1), required for upgrades from before 14.3
#- EGW_EXTRA_APP_OLDAPI=https://github.com/EGroupware/phpgwapi.git https://github.com/EGroupware/etemplate.git
#
# XDEBUG_REMOTE_HOST need to be set, if the host running the IDE is different from 172.17.0.1 (Mac can use docker.for.mac.localhost)
- XDEBUG_REMOTE_HOST=172.17.0.1
# XDEBUG_REMOTE_HOST need to be set, Docker Desktop can use host.docker.internal or Linux the docker0 interface 172.17.0.1
- XDEBUG_REMOTE_HOST=host.docker.internal
restart: always
depends_on:
- db
@ -228,7 +228,14 @@ services:
# Rocket.Chat server
#rocketchat:
# image: quay.io/egroupware/rocket.chat:latest
# command: bash -c 'for i in `seq 1 30`; do node main.js && s=$$? && break || s=$$?; echo "Tried $$i times. Waiting 5 secs..."; sleep 5; done; (exit $$s)'
# command: >
# sh -c
# "while true; do
# node main.js &&
# s=$$? && break || s=$$?;
# echo \"Could not reach MongoDB. Waiting 5 secs ...\";
# sleep 5;
# done; (exit $$s)"
# restart: unless-stopped
# volumes:
# - $PWD/data/default/rocketchat/uploads:/app/uploads

View File

@ -218,7 +218,14 @@ services:
# Rocket.Chat server
rocketchat:
image: rocketchat/rocket.chat:latest
command: bash -c 'for i in `seq 1 30`; do node main.js && s=$$? && break || s=$$?; echo "Tried $$i times. Waiting 5 secs..."; sleep 5; done; (exit $$s)'
command: >
sh -c
"while true; do
node main.js &&
s=$$? && break || s=$$?;
echo \"Could not reach MongoDB. Waiting 5 secs ...\";
sleep 5;
done; (exit $$s)"
restart: unless-stopped
volumes:
- $PWD/data/default/rocketchat/uploads:/app/uploads

View File

@ -54,6 +54,7 @@ $overwrites = [
'Et2InputWidget' => [
'.attrs' => [
'tabindex' => 'int',
'value' => 'string',
],
],
'Et2Textbox' => [
@ -61,8 +62,12 @@ $overwrites = [
'placeholder' => 'string',
'maxlength' => 'int',
'size' => 'int',
'type' => 'string',
],
],
'et2-textbox' => [
'.children' => ['.quantity' => 'optional', 'et2-image'],
],
'Et2InvokerMixin' => 'Et2TextBox',
'et2-description' => [
'.attrs' => [
@ -75,6 +80,7 @@ $overwrites = [
'rows' => 'int',
'resizeRatio' => 'number', // is this correct
'size' => 'int',
'placeholder' => 'string',
],
],
'et2-date' => [
@ -111,6 +117,9 @@ $overwrites = [
'et2-tab-panel' => null,
'et2-details' => [
'.children' => 'Widgets',
'.attrs' => [
'summary' => 'string',
],
],
'et2-split' => [
'.children' => 'Widgets',
@ -129,6 +138,7 @@ $overwrites = [
'.attrs' => [
'image' => 'string',
'noSubmit' => 'boolean',
'hideOnReadonly' => 'boolean',
],
],
'Et2ButtonIcon' => 'Et2Button', // no inheritance from Et2Button, but Et2ButtonMixin, which is not recognised

View File

@ -25,11 +25,11 @@
|tracker-value|records-value|hidden|radio|radiogroup|diff|styles|customfields|customfields-list|html
|htmlarea|toolbar|historylog|hrule|file|progress|vfs|vfs-name|vfs-size|vfs-mode|vfs-upload|video
|audio|barcode|itempicker|script|countdown|customfields-types|nextmatch|nextmatch-header
|nextmatch-customfields|nextmatch-sortheader|et2-avatar|et2-avatar-group|et2-lavatar|et2-button
|et2-button-icon|et2-button-scroll|et2-button-timestamp|et2-checkbox|et2-colorpicker|et2-date
|nextmatch-customfields|nextmatch-sortheader|et2-avatar|et2-avatar-group|et2-lavatar|et2-checkbox
|et2-button|et2-button-icon|et2-button-scroll|et2-button-timestamp|et2-colorpicker|et2-date
|et2-date-duration|et2-date-range|et2-date-since|et2-date-time|et2-date-timeonly|et2-date-time-today
|et2-description|et2-label|et2-dialog|et2-dropdown-button|et2-email|et2-favorites|et2-iframe
|et2-appicon|et2-image|et2-link|et2-link-add|et2-link-apps|et2-link-entry|et2-link-list
|et2-description|et2-label|et2-dialog|et2-merge-dialog|et2-dropdown-button|et2-email|et2-favorites
|et2-iframe|et2-appicon|et2-image|et2-link|et2-link-add|et2-link-apps|et2-link-entry|et2-link-list
|et2-link-paste-dialog|et2-link-search|et2-link-string|et2-link-to|et2-portlet|et2-listbox
|et2-select|et2-spinner|et2-switch|et2-textarea|et2-number|et2-password|et2-searchbox|et2-textbox
|et2-tree|et2-tree-dropdown|et2-tree-cat|et2-url|et2-url-email|et2-url-fax|et2-url-phone
@ -1385,6 +1385,38 @@
span CDATA #IMPLIED
slot CDATA #IMPLIED>
<!ELEMENT et2-checkbox EMPTY>
<!ATTLIST et2-checkbox
selectedValue CDATA 'true'
unselectedValue CDATA ''
label CDATA #IMPLIED
readonly (false|true|1) 'false'
required (false|true|1) 'false'
onchange CDATA #IMPLIED
autofocus (false|true|1) #IMPLIED
autocomplete CDATA 'on'
ariaLabel CDATA #IMPLIED
ariaDescription CDATA #IMPLIED
helpText CDATA #IMPLIED
class CDATA #IMPLIED
disabled (false|true|1) 'false'
hidden (false|true|1) #IMPLIED
accesskey CDATA #IMPLIED
parentId CDATA #IMPLIED
statustext CDATA #IMPLIED
onclick CDATA #IMPLIED
noLang (false|true|1) #IMPLIED
align CDATA #IMPLIED
data CDATA #IMPLIED
id CDATA #IMPLIED
width CDATA #IMPLIED
height CDATA #IMPLIED
span CDATA #IMPLIED
slot CDATA #IMPLIED
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-button EMPTY>
<!ATTLIST et2-button
@ -1414,7 +1446,9 @@
slot CDATA #IMPLIED
image CDATA #IMPLIED
noSubmit (false|true|1) #IMPLIED
tabindex CDATA #IMPLIED>
hideOnReadonly (false|true|1) #IMPLIED
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-button-icon EMPTY>
@ -1445,7 +1479,9 @@
slot CDATA #IMPLIED
image CDATA #IMPLIED
noSubmit (false|true|1) #IMPLIED
tabindex CDATA #IMPLIED>
hideOnReadonly (false|true|1) #IMPLIED
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-button-scroll EMPTY>
@ -1456,7 +1492,8 @@
span CDATA #IMPLIED
slot CDATA #IMPLIED
image CDATA #IMPLIED
noSubmit (false|true|1) #IMPLIED>
noSubmit (false|true|1) #IMPLIED
hideOnReadonly (false|true|1) #IMPLIED>
<!ELEMENT et2-button-timestamp EMPTY>
@ -1490,38 +1527,9 @@
slot CDATA #IMPLIED
image CDATA #IMPLIED
noSubmit (false|true|1) #IMPLIED
tabindex CDATA #IMPLIED>
<!ELEMENT et2-checkbox EMPTY>
<!ATTLIST et2-checkbox
selectedValue CDATA 'true'
unselectedValue CDATA ''
label CDATA #IMPLIED
readonly (false|true|1) 'false'
required (false|true|1) 'false'
onchange CDATA #IMPLIED
autofocus (false|true|1) #IMPLIED
autocomplete CDATA 'on'
ariaLabel CDATA #IMPLIED
ariaDescription CDATA #IMPLIED
helpText CDATA #IMPLIED
class CDATA #IMPLIED
disabled (false|true|1) 'false'
hidden (false|true|1) #IMPLIED
accesskey CDATA #IMPLIED
parentId CDATA #IMPLIED
statustext CDATA #IMPLIED
onclick CDATA #IMPLIED
noLang (false|true|1) #IMPLIED
align CDATA #IMPLIED
data CDATA #IMPLIED
id CDATA #IMPLIED
width CDATA #IMPLIED
height CDATA #IMPLIED
span CDATA #IMPLIED
slot CDATA #IMPLIED
tabindex CDATA #IMPLIED>
hideOnReadonly (false|true|1) #IMPLIED
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-colorpicker EMPTY>
@ -1550,7 +1558,8 @@
height CDATA #IMPLIED
span CDATA #IMPLIED
slot CDATA #IMPLIED
tabindex CDATA #IMPLIED>
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-date EMPTY>
@ -1585,7 +1594,8 @@
slot CDATA #IMPLIED
yearRange CDATA #IMPLIED
dataFormat CDATA #IMPLIED
tabindex CDATA #IMPLIED>
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-date-duration EMPTY>
@ -1622,7 +1632,8 @@
height CDATA #IMPLIED
span CDATA #IMPLIED
slot CDATA #IMPLIED
tabindex CDATA #IMPLIED>
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-date-range EMPTY>
@ -1708,7 +1719,8 @@
height CDATA #IMPLIED
span CDATA #IMPLIED
slot CDATA #IMPLIED
tabindex CDATA #IMPLIED>
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-date-timeonly EMPTY>
@ -1741,7 +1753,8 @@
height CDATA #IMPLIED
span CDATA #IMPLIED
slot CDATA #IMPLIED
tabindex CDATA #IMPLIED>
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-date-time-today EMPTY>
@ -1849,6 +1862,28 @@
span CDATA #IMPLIED
slot CDATA #IMPLIED>
<!ELEMENT et2-merge-dialog EMPTY>
<!ATTLIST et2-merge-dialog
application CDATA #IMPLIED
path CDATA #IMPLIED
class CDATA #IMPLIED
disabled (false|true|1) 'false'
hidden (false|true|1) #IMPLIED
accesskey CDATA #IMPLIED
parentId CDATA #IMPLIED
statustext CDATA #IMPLIED
label CDATA #IMPLIED
onclick CDATA #IMPLIED
noLang (false|true|1) #IMPLIED
align CDATA #IMPLIED
data CDATA #IMPLIED
id CDATA #IMPLIED
width CDATA #IMPLIED
height CDATA #IMPLIED
span CDATA #IMPLIED
slot CDATA #IMPLIED>
<!ELEMENT et2-dropdown-button EMPTY>
<!ATTLIST et2-dropdown-button
@ -1882,7 +1917,7 @@
<!ELEMENT et2-email EMPTY>
<!ATTLIST et2-email
value CDATA '[]'
value CDATA #IMPLIED
placeholder CDATA ''
allowDragAndDrop CDATA 'true'
allowPlaceholder (false|true|1) #IMPLIED
@ -2219,7 +2254,7 @@
noLang (false|true|1) #IMPLIED
align CDATA #IMPLIED
data CDATA #IMPLIED
value CDATA '[]'
value CDATA #IMPLIED
title CDATA 'Select'
mode CDATA #IMPLIED
buttonLabel CDATA 'Select'
@ -2484,7 +2519,8 @@
height CDATA #IMPLIED
span CDATA #IMPLIED
slot CDATA #IMPLIED
tabindex CDATA #IMPLIED>
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-textarea EMPTY>
@ -2518,7 +2554,9 @@
rows CDATA #IMPLIED
resizeRatio CDATA #IMPLIED
size CDATA #IMPLIED
tabindex CDATA #IMPLIED>
placeholder CDATA #IMPLIED
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-number EMPTY>
@ -2556,7 +2594,9 @@
placeholder CDATA #IMPLIED
maxlength CDATA #IMPLIED
size CDATA #IMPLIED
tabindex CDATA #IMPLIED>
type CDATA #IMPLIED
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-password EMPTY>
@ -2592,7 +2632,9 @@
placeholder CDATA #IMPLIED
maxlength CDATA #IMPLIED
size CDATA #IMPLIED
tabindex CDATA #IMPLIED>
type CDATA #IMPLIED
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-searchbox EMPTY>
@ -2628,9 +2670,11 @@
placeholder CDATA #IMPLIED
maxlength CDATA #IMPLIED
size CDATA #IMPLIED
tabindex CDATA #IMPLIED>
type CDATA #IMPLIED
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-textbox EMPTY>
<!ELEMENT et2-textbox (et2-image)?>
<!ATTLIST et2-textbox
validator CDATA #IMPLIED
@ -2662,7 +2706,9 @@
placeholder CDATA #IMPLIED
maxlength CDATA #IMPLIED
size CDATA #IMPLIED
tabindex CDATA #IMPLIED>
type CDATA #IMPLIED
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-tree EMPTY>
@ -2815,7 +2861,9 @@
placeholder CDATA #IMPLIED
maxlength CDATA #IMPLIED
size CDATA #IMPLIED
tabindex CDATA #IMPLIED>
type CDATA #IMPLIED
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-url-email EMPTY>
@ -2850,7 +2898,9 @@
placeholder CDATA #IMPLIED
maxlength CDATA #IMPLIED
size CDATA #IMPLIED
tabindex CDATA #IMPLIED>
type CDATA #IMPLIED
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-url-fax EMPTY>
@ -2884,7 +2934,9 @@
placeholder CDATA #IMPLIED
maxlength CDATA #IMPLIED
size CDATA #IMPLIED
tabindex CDATA #IMPLIED>
type CDATA #IMPLIED
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-url-phone EMPTY>
@ -2918,7 +2970,9 @@
placeholder CDATA #IMPLIED
maxlength CDATA #IMPLIED
size CDATA #IMPLIED
tabindex CDATA #IMPLIED>
type CDATA #IMPLIED
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-vfs-mime EMPTY>
@ -2983,7 +3037,7 @@
<!ATTLIST et2-vfs-select
image CDATA #IMPLIED
value CDATA '[]'
value CDATA #IMPLIED
title CDATA 'Select'
mode CDATA #IMPLIED
buttonLabel CDATA 'Select'
@ -3022,7 +3076,7 @@
<!ELEMENT et2-vfs-select-dialog EMPTY>
<!ATTLIST et2-vfs-select-dialog
value CDATA '[]'
value CDATA #IMPLIED
title CDATA 'Select'
mode CDATA #IMPLIED
buttonLabel CDATA 'Select'
@ -4174,7 +4228,8 @@
width CDATA #IMPLIED
height CDATA #IMPLIED
span CDATA #IMPLIED
slot CDATA #IMPLIED>
slot CDATA #IMPLIED
summary CDATA #IMPLIED>
<!ELEMENT et2-split (%Widgets;)+>
@ -4341,7 +4396,8 @@
span CDATA #IMPLIED
slot CDATA #IMPLIED
emptyLabel CDATA #IMPLIED
tabindex CDATA #IMPLIED>
tabindex CDATA #IMPLIED
value CDATA #IMPLIED>
<!ELEMENT et2-nextmatch-header-entry EMPTY>
@ -4418,4 +4474,4 @@
slot CDATA #IMPLIED
rows CDATA #IMPLIED
tabindex CDATA #IMPLIED
allowFreeEntries (false|true|1) #IMPLIED>
allowFreeEntries (false|true|1) #IMPLIED>

View File

@ -66,11 +66,11 @@
<ref name="et2-avatar"/>
<ref name="et2-avatar-group"/>
<ref name="et2-lavatar"/>
<ref name="et2-checkbox"/>
<ref name="et2-button"/>
<ref name="et2-button-icon"/>
<ref name="et2-button-scroll"/>
<ref name="et2-button-timestamp"/>
<ref name="et2-checkbox"/>
<ref name="et2-colorpicker"/>
<ref name="et2-date"/>
<ref name="et2-date-duration"/>
@ -82,6 +82,7 @@
<ref name="et2-description"/>
<ref name="et2-label"/>
<ref name="et2-dialog"/>
<ref name="et2-merge-dialog"/>
<ref name="et2-dropdown-button"/>
<ref name="et2-email"/>
<ref name="et2-favorites"/>
@ -4953,6 +4954,134 @@
<attribute name="slot"/>
</optional>
</define>
<define name="et2-checkbox">
<element name="et2-checkbox">
<ref name="attlist.et2-checkbox"/>
<empty/>
</element>
</define>
<define name="attlist.et2-checkbox" combine="interleave">
<optional>
<attribute name="selectedValue" a:defaultValue="true"/>
</optional>
<optional>
<attribute name="unselectedValue" a:defaultValue=""/>
</optional>
<optional>
<attribute name="label"/>
</optional>
<optional>
<attribute name="readonly" a:defaultValue="false">
<choice>
<value>false</value>
<value>true</value>
<value>1</value>
</choice>
</attribute>
</optional>
<optional>
<attribute name="required" a:defaultValue="false">
<choice>
<value>false</value>
<value>true</value>
<value>1</value>
</choice>
</attribute>
</optional>
<optional>
<attribute name="onchange"/>
</optional>
<optional>
<attribute name="autofocus">
<choice>
<value>false</value>
<value>true</value>
<value>1</value>
</choice>
</attribute>
</optional>
<optional>
<attribute name="autocomplete" a:defaultValue="on"/>
</optional>
<optional>
<attribute name="ariaLabel"/>
</optional>
<optional>
<attribute name="ariaDescription"/>
</optional>
<optional>
<attribute name="helpText"/>
</optional>
<optional>
<attribute name="class"/>
</optional>
<optional>
<attribute name="disabled" a:defaultValue="false">
<choice>
<value>false</value>
<value>true</value>
<value>1</value>
</choice>
</attribute>
</optional>
<optional>
<attribute name="hidden">
<choice>
<value>false</value>
<value>true</value>
<value>1</value>
</choice>
</attribute>
</optional>
<optional>
<attribute name="accesskey"/>
</optional>
<optional>
<attribute name="parentId"/>
</optional>
<optional>
<attribute name="statustext"/>
</optional>
<optional>
<attribute name="onclick"/>
</optional>
<optional>
<attribute name="noLang">
<choice>
<value>false</value>
<value>true</value>
<value>1</value>
</choice>
</attribute>
</optional>
<optional>
<attribute name="align"/>
</optional>
<optional>
<attribute name="data"/>
</optional>
<optional>
<attribute name="id"/>
</optional>
<optional>
<attribute name="width"/>
</optional>
<optional>
<attribute name="height"/>
</optional>
<optional>
<attribute name="span"/>
</optional>
<optional>
<attribute name="slot"/>
</optional>
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-button">
<element name="et2-button">
<ref name="attlist.et2-button"/>
@ -5080,9 +5209,21 @@
</choice>
</attribute>
</optional>
<optional>
<attribute name="hideOnReadonly">
<choice>
<value>false</value>
<value>true</value>
<value>1</value>
</choice>
</attribute>
</optional>
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-button-icon">
<element name="et2-button-icon">
@ -5211,9 +5352,21 @@
</choice>
</attribute>
</optional>
<optional>
<attribute name="hideOnReadonly">
<choice>
<value>false</value>
<value>true</value>
<value>1</value>
</choice>
</attribute>
</optional>
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-button-scroll">
<element name="et2-button-scroll">
@ -5249,6 +5402,15 @@
</choice>
</attribute>
</optional>
<optional>
<attribute name="hideOnReadonly">
<choice>
<value>false</value>
<value>true</value>
<value>1</value>
</choice>
</attribute>
</optional>
</define>
<define name="et2-button-timestamp">
<element name="et2-button-timestamp">
@ -5387,27 +5549,7 @@
</attribute>
</optional>
<optional>
<attribute name="tabindex"/>
</optional>
</define>
<define name="et2-checkbox">
<element name="et2-checkbox">
<ref name="attlist.et2-checkbox"/>
<empty/>
</element>
</define>
<define name="attlist.et2-checkbox" combine="interleave">
<optional>
<attribute name="selectedValue" a:defaultValue="true"/>
</optional>
<optional>
<attribute name="unselectedValue" a:defaultValue=""/>
</optional>
<optional>
<attribute name="label"/>
</optional>
<optional>
<attribute name="readonly" a:defaultValue="false">
<attribute name="hideOnReadonly">
<choice>
<value>false</value>
<value>true</value>
@ -5415,105 +5557,12 @@
</choice>
</attribute>
</optional>
<optional>
<attribute name="required" a:defaultValue="false">
<choice>
<value>false</value>
<value>true</value>
<value>1</value>
</choice>
</attribute>
</optional>
<optional>
<attribute name="onchange"/>
</optional>
<optional>
<attribute name="autofocus">
<choice>
<value>false</value>
<value>true</value>
<value>1</value>
</choice>
</attribute>
</optional>
<optional>
<attribute name="autocomplete" a:defaultValue="on"/>
</optional>
<optional>
<attribute name="ariaLabel"/>
</optional>
<optional>
<attribute name="ariaDescription"/>
</optional>
<optional>
<attribute name="helpText"/>
</optional>
<optional>
<attribute name="class"/>
</optional>
<optional>
<attribute name="disabled" a:defaultValue="false">
<choice>
<value>false</value>
<value>true</value>
<value>1</value>
</choice>
</attribute>
</optional>
<optional>
<attribute name="hidden">
<choice>
<value>false</value>
<value>true</value>
<value>1</value>
</choice>
</attribute>
</optional>
<optional>
<attribute name="accesskey"/>
</optional>
<optional>
<attribute name="parentId"/>
</optional>
<optional>
<attribute name="statustext"/>
</optional>
<optional>
<attribute name="onclick"/>
</optional>
<optional>
<attribute name="noLang">
<choice>
<value>false</value>
<value>true</value>
<value>1</value>
</choice>
</attribute>
</optional>
<optional>
<attribute name="align"/>
</optional>
<optional>
<attribute name="data"/>
</optional>
<optional>
<attribute name="id"/>
</optional>
<optional>
<attribute name="width"/>
</optional>
<optional>
<attribute name="height"/>
</optional>
<optional>
<attribute name="span"/>
</optional>
<optional>
<attribute name="slot"/>
</optional>
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-colorpicker">
<element name="et2-colorpicker">
@ -5633,6 +5682,9 @@
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-date">
<element name="et2-date">
@ -5782,6 +5834,9 @@
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-date-duration">
<element name="et2-date-duration">
@ -5949,6 +6004,9 @@
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-date-range">
<element name="et2-date-range">
@ -6303,6 +6361,9 @@
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-date-timeonly">
<element name="et2-date-timeonly">
@ -6446,6 +6507,9 @@
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-date-time-today">
<element name="et2-date-time-today">
@ -6851,6 +6915,86 @@
<attribute name="slot"/>
</optional>
</define>
<define name="et2-merge-dialog">
<element name="et2-merge-dialog">
<ref name="attlist.et2-merge-dialog"/>
<empty/>
</element>
</define>
<define name="attlist.et2-merge-dialog" combine="interleave">
<optional>
<attribute name="application"/>
</optional>
<optional>
<attribute name="path"/>
</optional>
<optional>
<attribute name="class"/>
</optional>
<optional>
<attribute name="disabled" a:defaultValue="false">
<choice>
<value>false</value>
<value>true</value>
<value>1</value>
</choice>
</attribute>
</optional>
<optional>
<attribute name="hidden">
<choice>
<value>false</value>
<value>true</value>
<value>1</value>
</choice>
</attribute>
</optional>
<optional>
<attribute name="accesskey"/>
</optional>
<optional>
<attribute name="parentId"/>
</optional>
<optional>
<attribute name="statustext"/>
</optional>
<optional>
<attribute name="label"/>
</optional>
<optional>
<attribute name="onclick"/>
</optional>
<optional>
<attribute name="noLang">
<choice>
<value>false</value>
<value>true</value>
<value>1</value>
</choice>
</attribute>
</optional>
<optional>
<attribute name="align"/>
</optional>
<optional>
<attribute name="data"/>
</optional>
<optional>
<attribute name="id"/>
</optional>
<optional>
<attribute name="width"/>
</optional>
<optional>
<attribute name="height"/>
</optional>
<optional>
<attribute name="span"/>
</optional>
<optional>
<attribute name="slot"/>
</optional>
</define>
<define name="et2-dropdown-button">
<element name="et2-dropdown-button">
<ref name="attlist.et2-dropdown-button"/>
@ -6981,7 +7125,7 @@
</define>
<define name="attlist.et2-email" combine="interleave">
<optional>
<attribute name="value" a:defaultValue="[]"/>
<attribute name="value"/>
</optional>
<optional>
<attribute name="placeholder" a:defaultValue=""/>
@ -8342,7 +8486,7 @@
<attribute name="data"/>
</optional>
<optional>
<attribute name="value" a:defaultValue="[]"/>
<attribute name="value"/>
</optional>
<optional>
<attribute name="title" a:defaultValue="Select"/>
@ -9403,6 +9547,9 @@
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-textarea">
<element name="et2-textarea">
@ -9534,9 +9681,15 @@
<optional>
<attribute name="size"/>
</optional>
<optional>
<attribute name="placeholder"/>
</optional>
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-number">
<element name="et2-number">
@ -9680,9 +9833,15 @@
<optional>
<attribute name="size"/>
</optional>
<optional>
<attribute name="type"/>
</optional>
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-password">
<element name="et2-password">
@ -9826,9 +9985,15 @@
<optional>
<attribute name="size"/>
</optional>
<optional>
<attribute name="type"/>
</optional>
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-searchbox">
<element name="et2-searchbox">
@ -9978,14 +10143,22 @@
<optional>
<attribute name="size"/>
</optional>
<optional>
<attribute name="type"/>
</optional>
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-textbox">
<element name="et2-textbox">
<ref name="attlist.et2-textbox"/>
<empty/>
<optional>
<ref name="et2-image"/>
</optional>
</element>
</define>
<define name="attlist.et2-textbox" combine="interleave">
@ -10112,9 +10285,15 @@
<optional>
<attribute name="size"/>
</optional>
<optional>
<attribute name="type"/>
</optional>
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-tree">
<element name="et2-tree">
@ -10741,9 +10920,15 @@
<optional>
<attribute name="size"/>
</optional>
<optional>
<attribute name="type"/>
</optional>
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-url-email">
<element name="et2-url-email">
@ -10878,9 +11063,15 @@
<optional>
<attribute name="size"/>
</optional>
<optional>
<attribute name="type"/>
</optional>
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-url-fax">
<element name="et2-url-fax">
@ -11012,9 +11203,15 @@
<optional>
<attribute name="size"/>
</optional>
<optional>
<attribute name="type"/>
</optional>
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-url-phone">
<element name="et2-url-phone">
@ -11146,9 +11343,15 @@
<optional>
<attribute name="size"/>
</optional>
<optional>
<attribute name="type"/>
</optional>
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-vfs-mime">
<element name="et2-vfs-mime">
@ -11390,7 +11593,7 @@
<attribute name="image"/>
</optional>
<optional>
<attribute name="value" a:defaultValue="[]"/>
<attribute name="value"/>
</optional>
<optional>
<attribute name="title" a:defaultValue="Select"/>
@ -11545,7 +11748,7 @@
</define>
<define name="attlist.et2-vfs-select-dialog" combine="interleave">
<optional>
<attribute name="value" a:defaultValue="[]"/>
<attribute name="value"/>
</optional>
<optional>
<attribute name="title" a:defaultValue="Select"/>
@ -16295,6 +16498,9 @@
<optional>
<attribute name="slot"/>
</optional>
<optional>
<attribute name="summary"/>
</optional>
</define>
<define name="et2-split">
<element name="et2-split">
@ -16979,6 +17185,9 @@
<optional>
<attribute name="tabindex"/>
</optional>
<optional>
<attribute name="value"/>
</optional>
</define>
<define name="et2-nextmatch-header-entry">
<element name="et2-nextmatch-header-entry">
@ -17299,4 +17508,4 @@
</attribute>
</optional>
</define>
</grammar>
</grammar>

View File

@ -0,0 +1,70 @@
#!/usr/bin/env php
<?php
/**
* Convert Lotus Structured Text to CSV to e.g. use CSV Import
*
* Writes CSV file to stdout with:
* - separator: ,
* - enclosure: "
* - escape: \
* - eol: \n
*
* Header 1,Header 2,"Header, with comma",Header N
* Data1,Data2,Data3,DataN
* ...
*
* Usage: ./lotus-structured-text.php <filename>
* ./lotus-structured-text.php < <filename>
*/
if (PHP_SAPI !== 'cli')
{
die('This script can only be run from the command line.');
}
if (!isset($argv[1]))
{
$fp = fopen('php://stdin', 'r');
}
elseif (!($fp = fopen($argv[1], 'r')))
{
die("Unable to open '$argv[1]'!");
}
$ctrl_l = chr(ord("L")-ord("@"));
$records = $record = $keys = [];
while (($line = fgets($fp)) !== false)
{
$line = trim($line);
if (($line === '' || $line === $ctrl_l))
{
if ($record)
{
if (!$keys || array_diff(array_keys($record), $keys))
{
$keys = array_unique(array_merge($keys, array_keys($record)));
}
$records[] = $record;
}
$record = [];
}
else
{
list($key, $value) = preg_split('/: */', $line, 2)+['', ''];
$record[$key] = $value;
}
}
if ($record)
{
if (!$keys || array_diff(array_keys($record), $keys))
{
$keys = array_unique(array_merge($keys, array_keys($record)));
}
$records[] = $record;
}
fputcsv(STDOUT, $keys);
foreach($records as $record)
{
fputcsv(STDOUT, array_map(static function($key) use ($record) {
return $record[$key] ?? '';
}, $keys));
}

35
doc/merge-cal-events-by-uid.sh Executable file
View File

@ -0,0 +1,35 @@
#!/bin/bash
#
# This script merges multiple events with same uid existing due to importing each users calendar without participant or chair information.
#
# The event-owner is kinda random, as events are merged in the one with the smallest cal_id / earliest imported!
#
DB=${1:-egroupware}
# merge multiple single events or series masters with same uid
mysql $DB --execute "SELECT min(cal_id),cal_uid FROM egw_cal where cal_reference=0 group by cal_uid having count(*) > 1" --skip-column-names |
while read ID UUID; do
#echo "#$ID: $UUID"
cat << EOF | mysql $DB
update ignore egw_cal_user set cal_id=$ID where cal_id in (select cal_id from egw_cal where cal_uid='$UUID' and cal_reference=0);
delete from egw_cal_user where cal_id<>$ID AND cal_id in (select cal_id from egw_cal where cal_uid='$UUID' and cal_reference=0);
update ignore egw_cal_dates set cal_id=$ID where cal_id in (select cal_id from egw_cal where cal_uid='$UUID' and cal_reference=0);
delete from egw_cal_dates where cal_id<>$ID AND cal_id in (select cal_id from egw_cal where cal_uid='$UUID' and cal_reference=0);
delete from egw_cal_repeats where cal_id<>$ID AND cal_id in (select cal_id from egw_cal where cal_uid='$UUID' and cal_reference=0);
update egw_cal set cal_reference=$ID where cal_reference<>0 and cal_uid='$UUID';
delete from egw_cal where cal_id<>$ID AND cal_id in (select cal_id from egw_cal where cal_uid='$UUID' and cal_reference=0);
EOF
done
# merge multiple recurrences/exceptions at same time
mysql $DB --execute "SELECT min(cal_id),cal_uid,cal_recurrence FROM egw_cal where cal_reference<>0 group by cal_uid,cal_recurrence having count(*) > 1" --skip-column-names |
while read ID UUID RECURRENCE; do
#echo "#$ID: $UUID: RECURRENCE"
cat << EOF | mysql $DB
update ignore egw_cal_user set cal_id=$ID where cal_id in (select cal_id from egw_cal where cal_uid='$UUID' and cal_reference<>0 and cal_recurrence=$RECURRENCE);
delete from egw_cal_user where cal_id<>$ID AND cal_id in (select cal_id from egw_cal where cal_uid='$UUID' and cal_reference<>0 and cal_recurrence=$RECURRENCE);
delete from egw_cal_dates where cal_id<>$ID AND cal_id in (select cal_id from egw_cal where cal_uid='$UUID' and cal_reference<>0 and cal_recurrence=$RECURRENCE);
delete from egw_cal where cal_id<>$ID AND cal_id in (select cal_id from egw_cal where cal_uid='$UUID' and cal_reference<>0 and cal_recurrence=$RECURRENCE);
EOF
done

View File

@ -13,7 +13,8 @@ egw-framework#egw_fw_basecontainer {
/* Internals */
&::part(status-split) {
--max: 150px;
/* This limits the max size of the status panel */
--max: 32px;
}
&::part(header) {
@ -60,6 +61,10 @@ egw-framework#egw_fw_basecontainer {
div#egw_fw_sidebar_r {
position: initial;
top: initial;
et2-avatar, et2-lavatar {
--size: 26px;
}
}
.egw_fw_ui_sidemenu_entry_header {
@ -69,14 +74,6 @@ egw-framework#egw_fw_basecontainer {
}
}
egw-app {
&::part(name) {
display: flex;
align-items: center;
text-wrap: nowrap;
}
}
/*** HEADER ***/
#egw_fw_topmenu_info_items {
display: flex;
@ -164,6 +161,61 @@ egw-app {
/*** END HEADER ***/
div#egw_fw_basecontainer {
display: none;
/*** APPLICATION ***/
egw-app {
&::part(name) {
display: flex;
align-items: center;
text-wrap: nowrap;
}
&::part(content) {
flex-direction: column;
overflow-y:hidden;
}
& > div[id] > div {
height: 100%;
}
}
/*** END APPLICATION ***/
/*** WIDGETS ***/
/* This should mostly go away with webcomponents */
/* Get nextmatch sizing more nicely without messing with dynheight */
div.et2_nextmatch {
display: flex;
flex-direction: column;
align-items: stretch;
height: 100%;
width: 100%;
overflow: hidden;
& > div:not(:first-child) {
flex: 1 1 100%;
max-height: 100%;
}
.egwGridView_outer {
height: 100%;
thead tr th.optcol span.selectcols {
height: 9px;
padding: 4px 14px 0 2px;
margin-top: 4px;
background-image: url(../../../api/templates/default/images/selectcols.svg);
background-repeat: no-repeat;
background-size: 10px 10px;
display: inline-block;
background-position: top;
}
}
}
.egwGridView_scrollarea {
overflow-x: hidden;
overflow-y: auto;
}

View File

@ -41,15 +41,12 @@
<div slot="header-right" id="egw_fw_topmenu_info_items">
{topmenu_info_items}
</div>
<div slot="header">
<sl-switch slot="header" id="placeholders" checked>Placeholders</sl-switch>
</div>
<!-- status app is looking for this -->
<div slot="aside" id="egw_fw_sidebar_r"></div>
<!-- status app is looking for this, but it could slot itself -->
<div slot="status" id="egw_fw_sidebar_r"></div>
<!-- Currently open app -->
<egw-app name="{open_app_name}" url="{open_app_url}" active></egw-app>
<egw-app id="{open_app_name}" name="{open_app_name}" url="{open_app_url}" active></egw-app>
</egw-framework>

View File

@ -67,7 +67,7 @@ class kdots_framework extends Api\Framework\Ajax
parent::topmenu($vars, $apps);
$vars['topmenu_items'] = "<sl-menu>" . implode("\n", $this->topmenu_items) . "</sl-menu>";
$vars['topmenu_items'] = "<sl-menu id='egw_fw_topmenu_items'>" . implode("\n", $this->topmenu_items) . "</sl-menu>";
$vars['topmenu_info_items'] = '';
foreach($this->topmenu_info_items as $id => $item)
{

View File

@ -1,4 +1,4 @@
import {css, html, LitElement, nothing} from "lit";
import {css, html, LitElement, nothing, PropertyValues} from "lit";
import {customElement} from "lit/decorators/custom-element.js";
import {property} from "lit/decorators/property.js";
import {classMap} from "lit/directives/class-map.js";
@ -8,6 +8,7 @@ import styles from "./EgwFramework.styles";
import {egw} from "../../api/js/jsapi/egw_global";
import {SlDropdown, SlTab, SlTabGroup} from "@shoelace-style/shoelace";
import {EgwFrameworkApp} from "./EgwFrameworkApp";
import {until} from "lit/directives/until.js";
/**
* @summary Accessable, webComponent-based EGroupware framework
@ -95,11 +96,9 @@ export class EgwFramework extends LitElement
/**
* This is the list of all applications we know about
*
* @type {any[]}
*/
@property({type: Array, attribute: "application-list"})
applicationList = [];
applicationList : ApplicationInfo[] = [];
private get tabs() : SlTabGroup { return this.shadowRoot.querySelector("sl-tab-group");}
@ -109,7 +108,7 @@ export class EgwFramework extends LitElement
if(this.egw.window && this.egw.window.opener == null && !this.egw.window.framework)
{
// This works, but stops a lot else from working
//this.egw.window.framework = this;
this.egw.window.framework = this;
}
if(this.egw.window?.framework && this.egw.window?.framework !== this)
{
@ -118,6 +117,40 @@ export class EgwFramework extends LitElement
}
}
protected firstUpdated(_changedProperties : PropertyValues)
{
super.firstUpdated(_changedProperties);
// Load hidden apps like status, as long as they can be loaded
this.applicationList.forEach((app) =>
{
if(app.status == "5" && app.url && !app.url.match(/menuaction\=none/))
{
this.loadApp(app.name);
}
});
// Load additional tabs
Object.values(this.tabApps).forEach(app => this.loadApp(app.name));
// Init timer
this.egw.add_timer('topmenu_info_timer');
}
/**
* Special tabs that are not directly associated with an application (CRM)
* @type {[]}
* @private
*/
protected get tabApps() : { [id : string] : ApplicationInfo }
{
return JSON.parse(egw.getSessionItem('api', 'fw_tab_apps') || null) || {};
}
protected set tabApps(apps : { [id : string] : ApplicationInfo })
{
egw.setSessionItem('api', 'fw_tab_apps', JSON.stringify(apps));
}
get egw() : typeof egw
{
return window.egw ?? <typeof egw>{
@ -128,6 +161,21 @@ export class EgwFramework extends LitElement
};
}
/**
* A promise for if egw is loaded
*
* @returns {Promise<void>}
*/
getEgwComplete()
{
let egwLoading = Promise.resolve();
if(typeof this.egw.window['egw_ready'] !== "undefined")
{
egwLoading = this.egw.window['egw_ready'];
}
return egwLoading;
}
/**
*
* @param _function Framework function to be called on the server.
@ -153,10 +201,16 @@ export class EgwFramework extends LitElement
(menuaction ? '.' + menuaction[1] : '');
};
public getApplicationByName(appName)
{
return this.querySelector(`egw-app[name="${appName}"]`);
}
/**
* Load an application into the framework
*
* Loading is done by name, and we look up everything we need in the applicationList
* Loading is done by name, and we look up everything we need in the applicationList.
* If already loaded, this just returns the existing EgwFrameworkApp, optionally activated & with new URL loaded.
*
* @param {string} appname
* @param {boolean} active
@ -165,7 +219,7 @@ export class EgwFramework extends LitElement
*/
public loadApp(appname : string, active = false, url = null) : EgwFrameworkApp
{
const existing : EgwFrameworkApp = this.querySelector(`egw-app[name="${appname}"]`);
const existing : EgwFrameworkApp = this.querySelector(`egw-app[id="${appname}"]`);
if(existing)
{
if(active)
@ -174,37 +228,69 @@ export class EgwFramework extends LitElement
}
if(url)
{
existing.url = url;
existing.load(url);
}
return existing;
}
const app = this.applicationList.find(a => a.name == appname);
const app = this.applicationList.find(a => a.name == appname) ??
this.tabApps[appname];
if(!app)
{
console.log("Cannot load unknown app '" + appname + "'");
return null;
}
let appComponent = <EgwFrameworkApp>document.createElement("egw-app");
appComponent.id = appname;
appComponent.name = appname;
appComponent.setAttribute("id", appname);
appComponent.setAttribute("name", app.internalName || appname);
appComponent.url = url ?? app?.url;
if(app.title)
{
appComponent.title = app.title;
}
this.append(appComponent);
// App was not in the tab list
if(typeof app.opened == "undefined")
{
app.opened = this.shadowRoot.querySelectorAll("sl-tab").length;
// Need to update tabApps directly, reference doesn't work
if(typeof this.tabApps[appname] == "object")
{
let tabApps = {...this.tabApps};
tabApps[appname] = app;
this.tabApps = tabApps;
}
this.requestUpdate("applicationList");
}
// Wait until new tab is there to activate it
if(active)
if(active || app.active)
{
this.updateComplete.then(() =>
// Wait for egw
this.getEgwComplete().then(() =>
{
this.tabs.show(appname);
})
// Wait for redraw after getEgwComplete promise
this.updateComplete.then(() =>
{
// Tabs present
this.updateComplete.then(() =>
{
this.showTab(appname);
});
});
});
}
return appComponent;
}
public get activeApp() : EgwFrameworkApp
{
return this.querySelector("egw-app[active]");
}
/**
* Load a link into the framework
*
@ -214,7 +300,7 @@ export class EgwFramework extends LitElement
*/
public linkHandler(_link : string, _app : string)
{
//Determine the app string from the application parameter
// Determine the app string from the application parameter
let app = null;
if(_app && typeof _app == 'string')
{
@ -235,13 +321,13 @@ export class EgwFramework extends LitElement
// add target flag
_link += '&target=_tab';
const appname = app.appName + ":" + btoa(_link);
this.applicationList[appname] = {...app};
this.applicationList[appname]['name'] = appname;
this.applicationList[appname]['indexUrl'] = _link;
this.applicationList[appname]['tab'] = null;
this.applicationList[appname]['browser'] = null;
this.applicationList[appname]['title'] = 'view';
app = this.applicationList[appname];
this.applicationList.push({
...app,
name: appname,
url: _link,
title: 'view'
});
app = this.applicationList[this.applicationList.length - 1];
}
this.loadApp(app.name, true, _link);
}
@ -261,6 +347,111 @@ export class EgwFramework extends LitElement
}
}
public tabLinkHandler(_link : string, _extra = {
id: ""
})
{
const app = this.parseAppFromUrl(_link);
if(app)
{
const appname = app.name + "-" + btoa(_extra.id ? _extra.id : _link).replace(/=/g, 'i');
if(this.getApplicationByName(appname))
{
this.loadApp(appname, true, _link);
return appname;
}
// add target flag
_link += '&fw_target=' + appname;
// create an actual clone of existing app object
let clone = {
...app,
..._extra,
//isFrameworkTab: true, ??
name: appname,
internalName: app.name,
url: _link,
// Need to override to open, base app might already be opened
opened: undefined
};
// Store only in session
let tabApps = {...this.tabApps};
tabApps[appname] = clone;
this.tabApps = tabApps;
/* ??
this.applications[appname]['sidemenuEntry'] = this.sidemenuUi.addEntry(
this.applications[appname].displayName, this.applications[appname].icon,
function()
{
self.applicationTabNavigate(self.applications[appname], _link, false, -1, null);
}, this.applications[appname], appname);
*/
this.loadApp(appname, true);
return appname;
}
else
{
egw_alertHandler("No appropriate target application has been found.",
"Target link: " + _link);
}
}
/**
* Open a (centered) popup window with given size and url
*
* @param {string} _url
* @param {number} _width
* @param {number} _height
* @param {string} _windowName or "_blank"
* @param {string|boolean} _app app-name for framework to set correct opener or false for current app
* @param {boolean} _returnID true: return window, false: return undefined
* @param {type} _status "yes" or "no" to display status bar of popup
* @param {DOMWindow} _parentWnd parent window
* @returns {DOMWindow|undefined}
*/
public openPopup(_url, _width, _height, _windowName, _app, _returnID, _status, _parentWnd)
{
//Determine the window the popup should be opened in - normally this is the iframe of the currently active application
let parentWindow = _parentWnd || window;
let navigate = false;
let appEntry = null;
if(typeof _app != 'undefined' && _app !== false)
{
appEntry = this.getApplicationByName(_app);
if(appEntry && appEntry.browser == null)
{
navigate = true;
this.applicationTabNavigate(appEntry, appEntry.indexUrl);
}
}
else
{
appEntry = this.activeApp;
}
if(appEntry != null && appEntry.useIframe && (_app || !egw(parentWindow).is_popup()))
{
parentWindow = appEntry.iframe.contentWindow;
}
const windowID = egw(parentWindow).openPopup(_url, _width, _height, _windowName, _app, true, _status, true);
windowID.framework = this;
if(navigate)
{
window.setTimeout("framework.applicationTabNavigate(framework.activeApp, framework.activeApp.indexUrl);", 500);
}
if(_returnID !== false)
{
return windowID;
}
}
/**
* Tries to obtain the application from a menuaction
* @param {string} _url
@ -287,7 +478,7 @@ export class EgwFramework extends LitElement
*/
public async print()
{
const appElement : EgwFrameworkApp = this.querySelector("egw-app[active]");
const appElement : EgwFrameworkApp = this.activeApp;
try
{
if(appElement)
@ -318,11 +509,15 @@ export class EgwFramework extends LitElement
* @protected
*/
protected handleApplicationTabShow(event)
{
// Create & show app
this.showTab(event.target.activeTab.panel);
}
public showTab(appname)
{
this.querySelectorAll("egw-app").forEach(app => app.removeAttribute("active"));
// Create & show app
const appname = event.target.activeTab.panel;
let appComponent = this.querySelector(`egw-app#${appname}`);
if(!appComponent)
{
@ -331,7 +526,11 @@ export class EgwFramework extends LitElement
appComponent.setAttribute("active", "");
// Update the list on the server
this.updateTabs(event.target.activeTab);
const tabGroup : SlTabGroup = this.shadowRoot.querySelector("sl-tab-group.egw_fw__open_applications");
tabGroup.updateComplete.then(() =>
{
this.updateTabs(appComponent);
});
}
/**
@ -351,22 +550,73 @@ export class EgwFramework extends LitElement
else
{
// Show will update, but closing in the background we call directly
this.updateTabs(tabGroup.querySelector("sl-tab[active]"));
tabGroup.updateComplete.then(() =>
{
this.updateTabs(tabGroup.querySelector("sl-tab[active]"));
});
}
// Remove the tab + panel
tab.remove();
panel.remove();
if(panel)
{
panel.remove();
}
}
/**
* Store last status of tabs
* tab status being used in order to open all previous opened
* tabs and to activate the last active tab
*/
private updateTabs(activeTab)
{
let appList = [];
//Send the current tab list to the server
let data = this.assembleTabList(activeTab);
// Update session tabs
let tabs = {};
Object.keys(this.tabApps).forEach((t) =>
{
if(data.some(d => d.appName == t))
{
tabs[t] = this.tabApps[t];
tabs[t].active = t == activeTab.id;
}
});
this.tabApps = tabs;
//Serialize the tab list and check whether it really has changed since the last
//submit
var serialized = egw.jsonEncode(data);
if(serialized != this.serializedTabState)
{
this.serializedTabState = serialized;
if(this.tabApps)
{
this._setTabAppsSession(this.tabApps);
}
egw.jsonq('EGroupware\\Api\\Framework\\Ajax::ajax_tab_changed_state', [data]);
}
}
private assembleTabList(activeTab)
{
let appList = []
Array.from(this.shadowRoot.querySelectorAll("sl-tab-group.egw_fw__open_applications sl-tab")).forEach((tab : SlTab) =>
{
appList.push({appName: tab.panel, active: activeTab.panel == tab.panel})
appList.push({appName: tab.panel, active: activeTab.id == tab.panel})
});
this.egw.jsonq('EGroupware\\Api\\Framework\\Ajax::ajax_tab_changed_state', [appList]);
return appList;
}
private _setTabAppsSession(_tabApps)
{
if(_tabApps)
{
egw.setSessionItem('api', 'fw_tab_apps', JSON.stringify(_tabApps));
}
}
/**
@ -416,7 +666,7 @@ export class EgwFramework extends LitElement
}
classes[`egw_fw__layout-${this.layout}`] = true;
return html`
return html`${until(this.getEgwComplete().then(() => html`
<div class=${classMap(classes)} part="base">
<div class="egw_fw__banner" part="banner" role="banner">
<slot name="banner"><span class="placeholder">Banner</span></slot>
@ -435,8 +685,8 @@ export class EgwFramework extends LitElement
@sl-tab-show=${this.handleApplicationTabShow}
@sl-close=${this.handleApplicationTabClose}
>
${repeat(this.applicationList
.filter(app => typeof app.opened !== "undefined")
${repeat([...this.applicationList, ...Object.values(this.tabApps)]
.filter(app => typeof app.opened !== "undefined" && app.status !== "5")
.sort((a, b) => a.opened - b.opened), (app) => this._applicationTabTemplate(app))}
</sl-tab-group>
<slot name="header"><span class="placeholder">header</span></slot>
@ -460,6 +710,28 @@ export class EgwFramework extends LitElement
<slot name="footer"><span class="placeholder">footer</span></slot>
</footer>
</div>
`;
`), html`<span>Waiting for egw...</span>
<slot></slot>`)}`;
}
}
/**
* Information we keep and use about each app on the client side
* This might not be limited to actual EGw apps,
*/
export interface ApplicationInfo
{
/* Internal name, used for reference & indexing. Might not be an egw app, might have extra bits */
name : string,
/* Must be an egw app, used for the EgwFrameworkApp, preferences, etc. */
internalName? : string,
icon : string
title : string,
url : string,
/* What type of application (1: normal, 5: loaded but no tab) */
status : string,// = "1",
/* Is the app open, and at what place in the tab list */
opened? : number,
/* Is the app currently active */
active? : boolean// = false
}

View File

@ -137,20 +137,26 @@ export default css`
@media (min-width: 600px) {
.egw_fw_app__main {
grid-template-columns: [start left] min-content [ main] 1fr [right] min-content [end];
grid-template-rows: [start sub-header] fit-content(2em) [main] auto [footer] fit-content(2em) [end];
grid-template-rows: [start sub-header] fit-content(2em) [main] auto [footer] fit-content(4em) [end];
}
.egw_fw_app__aside {
overflow-y: hidden;
}
.egw_fw_app__aside_content {
.egw_fw_app__aside_content, .egw_fw_app__main_content {
overflow-x: hidden;
overflow-y: auto;
display: flex;
}
.egw_fw_app__main_content {
flex-direction: column;
align-items: stretch;
}
::slotted(*) {
height: 100%;
flex: 1 1 auto;
}
::slotted(iframe) {
@ -207,5 +213,12 @@ export default css`
}
}
/* End layout */
/* Styling */
.egw_fw_app__header sl-icon[name="three-dots-vertical"] {
padding: var(--sl-spacing-small);
}
`

View File

@ -11,6 +11,8 @@ import {HasSlotController} from "../../api/js/etemplate/Et2Widget/slot";
import type {EgwFramework} from "./EgwFramework";
import {etemplate2} from "../../api/js/etemplate/etemplate2";
import {et2_IPrint} from "../../api/js/etemplate/et2_core_interfaces";
import {repeat} from "lit/directives/repeat.js";
import {until} from "lit/directives/until.js";
/**
* @summary Application component inside EgwFramework
@ -89,9 +91,12 @@ export class EgwFrameworkApp extends LitElement
];
}
@property()
@property({reflect: true})
name = "Application name";
@property()
title : string = "";
@property()
url = "";
@ -133,6 +138,7 @@ export class EgwFrameworkApp extends LitElement
/** The application's content must be in an iframe instead of handled normally */
protected useIframe = false;
protected _sideboxData : any;
connectedCallback()
{
@ -145,11 +151,14 @@ export class EgwFrameworkApp extends LitElement
{
this.rightPanelInfo.preferenceWidth = typeof width !== "undefined" ? parseInt(width) : this.rightPanelInfo.defaultWidth;
});
this.addEventListener("load", this.handleEtemplateLoad);
}
disconnectedCallback()
{
super.disconnectedCallback();
this.removeEventListener("load", this.handleEtemplateLoad);
}
firstUpdated()
@ -165,7 +174,7 @@ export class EgwFrameworkApp extends LitElement
return result
}
protected load(url)
public load(url)
{
if(!url)
{
@ -175,6 +184,7 @@ export class EgwFrameworkApp extends LitElement
}
return;
}
this.url = url;
let targetUrl = "";
this.useIframe = true;
let matches = url.match(/\/index.php\?menuaction=([A-Za-z0-9_\.]*.*&ajax=true.*)$/);
@ -199,22 +209,26 @@ export class EgwFrameworkApp extends LitElement
return this.loadingPromise = this.egw.request(
this.framework.getMenuaction('ajax_exec', targetUrl, this.name),
[targetUrl]
).then((data : string[]) =>
).then((data : string | string[] | { DOMNodeID? : string } | { DOMNodeID? : string }[]) =>
{
if(!data)
{
return;
}
// Load request returns HTML. Shove it in.
if(typeof data == "string" || typeof data == "object" && typeof data[0] == "string")
{
render(html`${unsafeHTML(data.join(""))}`, this);
render(html`${unsafeHTML((<string[]>data).join(""))}`, this);
}
else
{
// We got some data, use it
if(data.DOMNodeID)
{
this.id = data.DOMNodeID;
}
const items = (Array.isArray(data) ? data : [data])
.filter(data => (typeof data.DOMNodeID == "string" && document.querySelector("[id='" + data.DOMNodeID + "']") == null));
render(html`${repeat(items, i => i.DOMNodeID, (item) => html`
<div id="${item.DOMNodeID}"></div>`)}`, this);
}
this.addEventListener("load", this.handleEtemplateLoad, {once: true});
// Might have just slotted aside content, hasSlotController will requestUpdate()
// but we need to do it anyway for translation
@ -226,13 +240,12 @@ export class EgwFrameworkApp extends LitElement
this.loadingPromise = new Promise((resolve, reject) =>
{
const timeout = setTimeout(() => reject(this.name + " load failed"), 5000);
this.addEventListener("load", () =>
render(this._iframeTemplate(), this);
this.querySelector("iframe").addEventListener("load", () =>
{
clearTimeout(timeout);
resolve()
}, {once: true});
render(this._iframeTemplate(), this);
});
// Might have just changed useIFrame, need to update to show that
this.requestUpdate();
@ -240,9 +253,15 @@ export class EgwFrameworkApp extends LitElement
}
}
public getMenuaction(_fun, _ajax_exec_url, appName = "")
{
return this.framework.getMenuaction(_fun, _ajax_exec_url, appName || this.name);
}
public setSidebox(sideboxData, hash?)
{
this._sideboxData = sideboxData;
this.requestUpdate();
}
public showLeft()
@ -300,9 +319,9 @@ export class EgwFrameworkApp extends LitElement
this.egw.loading_prompt(this.name, true, this.egw.lang('please wait...'), this, egwIsMobile() ? 'horizental' : 'spinner');
// Give framework a chance to deal, then reset the etemplates
appWindow.setTimeout(function()
appWindow.setTimeout(() =>
{
for(var i = 0; i < et2_list.length; i++)
for(let i = 0; i < et2_list.length; i++)
{
et2_list[i].widgetContainer.iterateOver(function(_widget)
{
@ -362,7 +381,7 @@ export class EgwFrameworkApp extends LitElement
get egw()
{
return window.egw ?? (<EgwFramework>this.parentElement).egw ?? null;
return window.egw(this.name) ?? (<EgwFramework>this.parentElement).egw ?? null;
}
get framework() : EgwFramework
@ -370,6 +389,11 @@ export class EgwFrameworkApp extends LitElement
return this.closest("egw-framework");
}
get appName() : string
{
return this.name;
}
private hasSideContent(side : "left" | "right")
{
return this.hasSlotController.test(`${side}-header`) ||
@ -382,8 +406,8 @@ export class EgwFrameworkApp extends LitElement
*/
protected handleEtemplateLoad(event)
{
const etemplate = etemplate2.getById(this.id);
if(!etemplate)
const etemplate = etemplate2.getById(event.target.id);
if(!etemplate || !event.composedPath().includes(this))
{
return;
}
@ -418,16 +442,25 @@ export class EgwFrameworkApp extends LitElement
this[`${event.target.panelInfo.side}Collapsed`] = newPosition == event.target.panelInfo.hiddenWidth;
let preferenceName = event.target.panelInfo.preference;
if(newPosition != event.target.panelInfo.preferenceWidth)
if(newPosition != event.target.panelInfo.preferenceWidth && !isNaN(newPosition))
{
event.target.panelInfo.preferenceWidth = newPosition;
if(this.resizeTimeout)
{
window.clearTimeout(this.resizeTimeout);
}
window.setTimeout(() =>
this.resizeTimeout = window.setTimeout(() =>
{
this.egw.set_preference(this.name, preferenceName, newPosition);
// Tell etemplates to resize
this.querySelectorAll("[id]").forEach(e =>
{
if(etemplate2.getById(e.id))
{
etemplate2.getById(e.id).resize(new Event("resize"));
}
});
}, 500);
}
}
@ -497,36 +530,109 @@ export class EgwFrameworkApp extends LitElement
protected _rightHeaderTemplate()
{
return html`
<sl-button-group>
<et2-button-icon nosubmit name="arrow-clockwise"
label=${this.egw.lang("Reload %1", this.egw.lang(this.name))}
statustext=${this.egw.lang("Reload %1", this.egw.lang(this.name))}
@click=${(e) =>
{
this.egw.refresh("", this.name);
/* Could also be this.load(false); this.load(this.url) */
}}
></et2-button-icon>
<et2-button-icon nosubmit name="printer"
label=${this.egw.lang("Print")}
statustext=${this.egw.lang("Print")}
@click=${(e) => this.framework.print()}
></et2-button-icon>
${this.egw.user('apps')['waffles'] !== "undefined" ? html`
<et2-button-icon nosubmit name="gear-wide"
label=${this.egw.lang("Site configuration for %1", this.egw.lang(this.name))}
statustext=${this.egw.lang("App configuration")}
@click=${(e) =>
{
// @ts-ignore
egw_link_handler(`/egroupware/index.php?menuaction=admin.admin_ui.index&load=admin.uiconfig.index&appname=${this.name}&ajax=true`, 'admin');
}}
></et2-button-icon>` : nothing
}
</sl-button-group>
<sl-tooltip content=${this.egw.lang("Application menu")}>
<sl-dropdown>
<sl-icon slot="trigger" name="three-dots-vertical"></sl-icon>
<sl-menu>
<sl-menu-item
@click=${(e) =>
{
this.egw.refresh("", this.name);
/* Could also be this.load(false); this.load(this.url) */
}}
>
<sl-icon slot="prefix" name="arrow-clockwise"></sl-icon>
${this.egw.lang("Reload %1", this.egw.lang(this.name))}
</sl-menu-item>
<sl-menu-item
@click=${(e) => this.framework.print()}
>
<sl-icon slot="prefix" name="printer"></sl-icon>
${this.egw.lang("Print")}
</sl-menu-item>
${this.egw.user('apps')['admin'] !== undefined ? html`
<sl-menu-item
@click=${(e) =>
{
// @ts-ignore
egw_link_handler(`/egroupware/index.php?menuaction=admin.admin_ui.index&load=admin.uiconfig.index&appname=${this.name}&ajax=true`, 'admin');
}}
>
<sl-icon slot="prefix" name="gear-wide"></sl-icon>
${this.egw.lang("App configuration")}
</sl-menu-item>
<sl-divider></sl-divider>
` : nothing}
${this._sideboxMenuTemplate()}
</sl-menu>
</sl-dropdown>
</sl-tooltip>
`;
}
protected _sideboxMenuTemplate()
{
if(!this._sideboxData)
{
return nothing;
}
return html`${repeat(this._sideboxData, (menu) => menu['menu_name'], (menu) =>
{
// No favorites here
if(menu["title"] == "Favorites" || menu["title"] == this.egw.lang("favorites"))
{
return html`
<sl-menu-item>
<et2-image style="width:1em;" src="fav_filter" slot="prefix"></et2-image>
${menu["title"]}
<et2-favorites-menu slot="submenu" application="${this.appName}"></et2-favorites-menu>
</sl-menu-item>
`;
}
// Just one thing, don't bother with submenu
if(menu["entries"].length == 1)
{
return this._sideboxMenuItemTemplate({...menu["entries"][0], lang_item: menu["title"]})
}
return html`
<sl-menu-item>
${menu["title"]}
<sl-menu slot="submenu">
${repeat(menu["entries"], (entry) =>
{
return this._sideboxMenuItemTemplate(entry);
})}
</sl-menu>
</sl-menu-item>`;
})}`;
}
/**
* An individual sub-item in the 3-dots menu
* @param item
* @returns {TemplateResult<1>}
*/
_sideboxMenuItemTemplate(item)
{
if(item["lang_item"] == "<hr />")
{
return html`
<sl-divider></sl-divider>`;
}
return html`
<sl-menu-item
?disabled=${!item["item_link"]}
@click=${() => this.egw.open_link(item["item_link"])}
>
${typeof item["icon_or_star"] == "string" && item["icon_or_star"].endsWith("bullet.svg") ? nothing : html`
<et2-image name=${item["icon_or_star"]}></et2-image>
`}
${item["lang_item"]}
</sl-menu-item>`;
}
render()
{
const hasLeftSlots = this.hasSideContent("left");
@ -552,12 +658,13 @@ export class EgwFrameworkApp extends LitElement
></sl-icon-button>`
: nothing
}
<h2>${this.egw?.lang(this.name) ?? this.name}</h2>
<h2>${this.title || this.egw?.lang(this.name) || this.name}</h2>
</div>
<header class="egw_fw_app__header" part="header">
<slot name="main-header"><span class="placeholder"> ${this.name} main-header</span></slot>
</header>
${this._rightHeaderTemplate()}
${until(this.framework.getEgwComplete().then(() => this._rightHeaderTemplate()), html`
<sl-spinner></sl-spinner>`)}
</div>
<div class="egw_fw_app__main" part="main">
<sl-split-panel class=${classMap({"egw_fw_app__outerSplit": true, "no-content": !hasLeftSlots})}

View File

@ -19,16 +19,11 @@ document.addEventListener('DOMContentLoaded', () =>
}
/* Set up listener on avatar menu */
const avatarMenu = document.querySelector("#topmenu_info_user_avatar");
avatarMenu.addEventListener("sl-select", (e : CustomEvent) =>
if(avatarMenu)
{
window.egw.open_link(e.detail.item.value);
});
/* Listener on placeholder checkbox */
// TODO: Remove this & the switch
document.querySelector("#placeholders").addEventListener("sl-change", (e) =>
{
document.querySelector("egw-framework").classList.toggle("placeholder", e.target.checked);
document.querySelector("egw-app").classList.toggle("placeholder", e.target.checked);
});
avatarMenu.addEventListener("sl-select", (e : CustomEvent) =>
{
window.egw.open_link(e.detail.item.value);
});
}
});

32
package-lock.json generated
View File

@ -11,7 +11,7 @@
"dependencies": {
"@bundled-es-modules/pdfjs-dist": "^2.5.207-rc1",
"@rollup/plugin-commonjs": "^24.0.1",
"@shoelace-style/shoelace": "2.15.0",
"@shoelace-style/shoelace": "2.15.1",
"@types/jquery": "^3.5.29",
"blueimp-gallery": "^3.4.0",
"colortranslator": "^1.9.2",
@ -3409,9 +3409,9 @@
"integrity": "sha512-Hf45HeO+vdQblabpyZOTxJ4ZeZsmIUYXXPmoYrrR4OJ5OKxL+bhMz5mK8JXgl7HsoEowfz7+e248UGi861de9Q=="
},
"node_modules/@shoelace-style/shoelace": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/@shoelace-style/shoelace/-/shoelace-2.15.0.tgz",
"integrity": "sha512-Lcg938Y8U2VsHqIYewzlt+H1rbrXC4GRSUkTJgXyF8/0YAOlI+srd5OSfIw+/LYmwLP2Peyh398Kae/6tg4PDA==",
"version": "2.15.1",
"resolved": "https://registry.npmjs.org/@shoelace-style/shoelace/-/shoelace-2.15.1.tgz",
"integrity": "sha512-3ecUw8gRwOtcZQ8kWWkjk4FTfObYQ/XIl3aRhxprESoOYV1cYhloYPsmQY38UoL3+pwJiZb5+LzX0l3u3Zl0GA==",
"dependencies": {
"@ctrl/tinycolor": "^4.0.2",
"@floating-ui/dom": "^1.5.3",
@ -3857,6 +3857,12 @@
"integrity": "sha512-ARATsLdrGPUnaBvxLhUlnltcMgn7pQG312S8ccdYlnyijabrX9RN/KN/iGj9Am96CoW8e/K9628BA7Bv4XHdrA==",
"dev": true
},
"node_modules/@types/prop-types": {
"version": "15.7.12",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
"integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
"peer": true
},
"node_modules/@types/qs": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz",
@ -3869,6 +3875,16 @@
"integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==",
"dev": true
},
"node_modules/@types/react": {
"version": "18.3.3",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz",
"integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
}
},
"node_modules/@types/resolve": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
@ -7089,6 +7105,12 @@
"node": ">=14"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"peer": true
},
"node_modules/custom-element-jet-brains-integration": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/custom-element-jet-brains-integration/-/custom-element-jet-brains-integration-1.2.1.tgz",
@ -12940,7 +12962,7 @@
"version": "2.79.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz",
"integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==",
"dev": true,
"devOptional": true,
"bin": {
"rollup": "dist/bin/rollup"
},

View File

@ -83,7 +83,7 @@
"dependencies": {
"@bundled-es-modules/pdfjs-dist": "^2.5.207-rc1",
"@rollup/plugin-commonjs": "^24.0.1",
"@shoelace-style/shoelace": "2.15.0",
"@shoelace-style/shoelace": "2.15.1",
"@types/jquery": "^3.5.29",
"blueimp-gallery": "^3.4.0",
"colortranslator": "^1.9.2",

View File

@ -27,7 +27,6 @@
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
* @author Stefan Reinhard <stefan.reinhard@pixelegg.de>
* @package pixelegg
* @version $Id$
*/
/**
* addapted from orginal styles.php
@ -2198,6 +2197,7 @@ div#loginMainDiv.stockLoginBackground div#centerBox form {
border-bottom: 1px solid silver;
padding-left: 25px;
background-color: transparent;
font-size: 100%;
}
#loginMainDiv div#centerBox form table.divLoginbox input:focus {
outline: none;

View File

@ -7,7 +7,6 @@
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
* @author Stefan Reinhard <stefan.reinhard@pixelegg.de>
* @package pixelegg
* @version $Id$
*/
/**
* addapted from orginal styles.php
@ -2178,6 +2177,7 @@ div#loginMainDiv.stockLoginBackground div#centerBox form {
border-bottom: 1px solid silver;
padding-left: 25px;
background-color: transparent;
font-size: 100%;
}
#loginMainDiv div#centerBox form table.divLoginbox input:focus {
outline: none;

View File

@ -7,7 +7,6 @@
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
* @author Stefan Reinhard <stefan.reinhard@pixelegg.de>
* @package pixelegg
* @version $Id$
*/
/**
* addapted from orginal styles.php

View File

@ -17,7 +17,6 @@
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
* @author Stefan Reinhard <stefan.reinhard@pixelegg.de>
* @package pixelegg
* @version $Id$
*/
/**
* addapted from orginal styles.php
@ -2188,6 +2187,7 @@ div#loginMainDiv.stockLoginBackground div#centerBox form {
border-bottom: 1px solid silver;
padding-left: 25px;
background-color: transparent;
font-size: 100%;
}
#loginMainDiv div#centerBox form table.divLoginbox input:focus {
outline: none;

View File

@ -341,6 +341,7 @@ div#loginMainDiv.stockLoginBackground {
border-bottom: 1px solid silver;
padding-left: 25px;
background-color: transparent;
font-size: 100%;
}
input:hover {}
input:focus {

View File

@ -188,7 +188,6 @@
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
* @author Stefan Reinhard <stefan.reinhard@pixelegg.de>
* @package pixelegg
* @version $Id$
*/
/**
* addapted from orginal styles.php
@ -2209,6 +2208,7 @@ div#loginMainDiv.stockLoginBackground div#centerBox form {
border-bottom: 1px solid silver;
padding-left: 25px;
background-color: transparent;
font-size: 100%;
}
#loginMainDiv div#centerBox form table.divLoginbox input:focus {
outline: none;