feat(updates): extension metadata settings, tab visibility, platform support #356

Merged
jmiller merged 4 commits from dev into main 2026-05-31 16:01:56 +00:00
15 changed files with 212 additions and 44 deletions
+24 -1
View File
@@ -24,9 +24,19 @@ type UpdateStreamConfig struct {
OwnerID int64 `xorm:"INDEX NOT NULL"` // org or user
RepoID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` // 0 = org-level default
StreamMode string `xorm:"NOT NULL DEFAULT 'joomla'"` // joomla, custom
Platform string `xorm:"NOT NULL DEFAULT 'joomla'"` // joomla, dolibarr, both
Platform string `xorm:"NOT NULL DEFAULT 'joomla'"` // joomla, dolibarr, both, wordpress, prestashop, drupal
LicensingEnabled bool `xorm:"NOT NULL DEFAULT false"` // master toggle for licensing system
RequireKey bool `xorm:"NOT NULL DEFAULT false"` // require license key for update feed
// Extension metadata — used in update feed generation.
ExtensionName string `xorm:"TEXT"` // element identifier (e.g. pkg_mokowaas, com_mokowaas)
DisplayName string `xorm:"TEXT"` // human-readable name (e.g. "Package - MokoWaaS")
Description string `xorm:"TEXT"` // short description for update feeds
ExtensionType string `xorm:"VARCHAR(50)"` // component, module, plugin, package, template, library
Maintainer string `xorm:"TEXT"` // maintainer/author name
MaintainerURL string `xorm:"TEXT"` // maintainer website
InfoURL string `xorm:"TEXT"` // extension info/product page URL
TargetVersion string `xorm:"TEXT"` // target platform version regex (e.g. "(5|6)\..*")
PHPMinimum string `xorm:"VARCHAR(20)"` // minimum PHP version (e.g. "8.1")
// CustomStreams is a JSON array of stream definitions.
// Each entry: {"name":"lts","suffix":"-lts","description":"Long-term support"}
CustomStreams string `xorm:"TEXT"`
@@ -121,6 +131,19 @@ func GetEffectiveStreams(ctx context.Context, ownerID, repoID int64) []StreamDef
return DefaultJoomlaStreams()
}
// GetEffectiveConfig returns the full config for a repo: repo override → org default.
func GetEffectiveConfig(ctx context.Context, ownerID, repoID int64) *UpdateStreamConfig {
repoCfg, err := GetRepoConfig(ctx, repoID)
if err == nil && repoCfg != nil {
return repoCfg
}
orgCfg, err := GetOrgConfig(ctx, ownerID)
if err == nil && orgCfg != nil {
return orgCfg
}
return nil
}
// SaveConfig creates or updates an update stream config.
func SaveConfig(ctx context.Context, cfg *UpdateStreamConfig) error {
existing := new(UpdateStreamConfig)
+3 -3
View File
@@ -417,8 +417,8 @@ func GetUsersWhoCanCreateOrgRepo(ctx context.Context, orgID int64) (map[int64]*u
And("team_user.org_id = ?", orgID).Find(&users)
}
// HasOrgOrUserVisible tells if the given user can see the given org or user
func HasOrgOrUserVisible(ctx context.Context, orgOrUser, user *user_model.User) bool {
// IsOwnerVisibleToDoer tells if the given user can see the given org or user
func IsOwnerVisibleToDoer(ctx context.Context, orgOrUser, user *user_model.User) bool {
// If user is nil, it's an anonymous user/request.
// The Ghost user is handled like an anonymous user.
if user == nil || user.IsGhost() {
@@ -446,7 +446,7 @@ func HasOrgsVisible(ctx context.Context, orgs []*Organization, user *user_model.
}
for _, org := range orgs {
if HasOrgOrUserVisible(ctx, org.AsUser(), user) {
if IsOwnerVisibleToDoer(ctx, org.AsUser(), user) {
return true
}
}
+11 -11
View File
@@ -378,18 +378,18 @@ func TestHasOrgVisibleTypePublic(t *testing.T) {
assert.NoError(t, organization.CreateOrganization(t.Context(), org, owner))
org = unittest.AssertExistsAndLoadBean(t,
&organization.Organization{Name: org.Name, Type: user_model.UserTypeOrganization})
test1 := organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), owner)
test2 := organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), org3)
test3 := organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), nil)
test1 := organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), owner)
test2 := organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), org3)
test3 := organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), nil)
assert.True(t, test1) // owner of org
assert.True(t, test2) // user not a part of org
assert.True(t, test3) // logged out user
restrictedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29, IsRestricted: true})
require.True(t, restrictedUser.IsRestricted)
assert.True(t, organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), restrictedUser))
assert.True(t, organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), restrictedUser))
defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)()
assert.False(t, organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), restrictedUser))
assert.False(t, organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), restrictedUser))
}
func TestHasOrgVisibleTypeLimited(t *testing.T) {
@@ -407,9 +407,9 @@ func TestHasOrgVisibleTypeLimited(t *testing.T) {
assert.NoError(t, organization.CreateOrganization(t.Context(), org, owner))
org = unittest.AssertExistsAndLoadBean(t,
&organization.Organization{Name: org.Name, Type: user_model.UserTypeOrganization})
test1 := organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), owner)
test2 := organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), org3)
test3 := organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), nil)
test1 := organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), owner)
test2 := organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), org3)
test3 := organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), nil)
assert.True(t, test1) // owner of org
assert.True(t, test2) // user not a part of org
assert.False(t, test3) // logged out user
@@ -430,9 +430,9 @@ func TestHasOrgVisibleTypePrivate(t *testing.T) {
assert.NoError(t, organization.CreateOrganization(t.Context(), org, owner))
org = unittest.AssertExistsAndLoadBean(t,
&organization.Organization{Name: org.Name, Type: user_model.UserTypeOrganization})
test1 := organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), owner)
test2 := organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), org3)
test3 := organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), nil)
test1 := organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), owner)
test2 := organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), org3)
test3 := organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), nil)
assert.True(t, test1) // owner of org
assert.False(t, test2) // user not a part of org
assert.False(t, test3) // logged out user
+1 -2
View File
@@ -427,8 +427,7 @@ func GetIndividualUserRepoPermission(ctx context.Context, repo *repo_model.Repos
// Prevent strangers from checking out public repo of private organization/users
// Allow user if they are a collaborator of a repo within a private user or a private organization but not a member of the organization itself
// TODO: rename it to "IsOwnerVisibleToDoer"
if !organization.HasOrgOrUserVisible(ctx, repo.Owner, user) && !isCollaborator {
if !organization.IsOwnerVisibleToDoer(ctx, repo.Owner, user) && !isCollaborator {
perm.AccessMode = perm_model.AccessModeNone
return perm, nil
}
+15
View File
@@ -2837,6 +2837,21 @@
"org.settings.enable_licensing_help": "Show the Licenses page in the org menu and enable license key management. Individual repos can also enable licensing independently.",
"org.settings.require_key": "Require license key for all update feeds",
"org.settings.require_key_help": "Update feeds return empty results unless a valid key is provided. Joomla clients will see a Download Key field. Individual repos can override this.",
"org.settings.extension_metadata": "Extension Metadata",
"org.settings.extension_metadata_desc": "Configure how this extension appears in update feeds. These fields are used when generating updates.xml, JSON feeds, and package metadata.",
"org.settings.update_platform": "Update Feed Format",
"org.settings.extension_name": "Element Name",
"org.settings.extension_name_help": "The unique extension identifier as registered in the CMS (e.g. pkg_mokowaas, com_akeebabackup).",
"org.settings.display_name": "Display Name",
"org.settings.display_name_help": "Human-readable name shown in the CMS update manager.",
"org.settings.extension_type": "Extension Type",
"org.settings.target_version": "Target Platform Version",
"org.settings.target_version_help": "Regex pattern for compatible CMS versions (e.g. (5|6)\\..*). Leave empty for all versions.",
"org.settings.maintainer": "Maintainer",
"org.settings.maintainer_url": "Maintainer URL",
"org.settings.info_url": "Info/Product URL",
"org.settings.info_url_help": "Link to the extension's product or documentation page.",
"org.settings.php_minimum": "Minimum PHP Version",
"org.settings.update_streams_heading": "Update Streams",
"org.settings.update_streams_desc": "Configure the default update streams for all repositories. Release tags are matched to streams by their suffix. Repos can override with per-repo settings.",
"org.settings.stream_mode": "Stream Mode",
+4 -4
View File
@@ -145,8 +145,8 @@ func GetUserOrgsPermissions(ctx *context.APIContext) {
op := api.OrganizationPermissions{}
if !organization.HasOrgOrUserVisible(ctx, o, ctx.Doer) {
ctx.APIErrorNotFound("HasOrgOrUserVisible", nil)
if !organization.IsOwnerVisibleToDoer(ctx, o, ctx.Doer) {
ctx.APIErrorNotFound("IsOwnerVisibleToDoer", nil)
return
}
@@ -311,8 +311,8 @@ func Get(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
if !organization.HasOrgOrUserVisible(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) {
ctx.APIErrorNotFound("HasOrgOrUserVisible", nil)
if !organization.IsOwnerVisibleToDoer(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) {
ctx.APIErrorNotFound("IsOwnerVisibleToDoer", nil)
return
}
+1 -1
View File
@@ -93,7 +93,7 @@ func prepareDoerCreateRepoInOrg(ctx *context.APIContext, orgName string) *organi
return nil
}
if !organization.HasOrgOrUserVisible(ctx, org.AsUser(), ctx.Doer) {
if !organization.IsOwnerVisibleToDoer(ctx, org.AsUser(), ctx.Doer) {
ctx.APIErrorNotFound()
return nil
}
+10
View File
@@ -40,9 +40,19 @@ func SettingsUpdateStreamsPost(ctx *context.Context) {
OwnerID: orgID,
RepoID: 0,
StreamMode: ctx.FormString("stream_mode"),
Platform: ctx.FormString("platform"),
CustomStreams: ctx.FormString("custom_streams"),
LicensingEnabled: ctx.FormString("licensing_enabled") == "on",
RequireKey: ctx.FormString("require_key") == "on",
ExtensionName: ctx.FormString("extension_name"),
DisplayName: ctx.FormString("display_name"),
Description: ctx.FormString("feed_description"),
ExtensionType: ctx.FormString("extension_type"),
Maintainer: ctx.FormString("maintainer"),
MaintainerURL: ctx.FormString("maintainer_url"),
InfoURL: ctx.FormString("info_url"),
TargetVersion: ctx.FormString("target_version"),
PHPMinimum: ctx.FormString("php_minimum"),
}
if cfg.StreamMode == "" {
+1 -1
View File
@@ -149,7 +149,7 @@ func determineAccessMode(ctx *Base, pkgOwner, doer *user_model.User) (perm.Acces
}
}
}
if accessMode == perm.AccessModeNone && organization.HasOrgOrUserVisible(ctx, pkgOwner, doer) {
if accessMode == perm.AccessModeNone && organization.IsOwnerVisibleToDoer(ctx, pkgOwner, doer) {
// 2. If user is unauthorized or no org member, check if org is visible
accessMode = perm.AccessModeRead
}
+56 -11
View File
@@ -125,8 +125,8 @@ func NormalizeChannel(ch string) string {
}
// GenerateJoomlaXML builds a Joomla-compatible updates.xml from repository releases.
// It returns the raw XML bytes. The element, maintainer, and target platform
// are derived from the repo name and owner.
// It returns the raw XML bytes. Extension metadata is read from the update stream config;
// falls back to repo name/owner when not configured.
// allowedChannels optionally restricts output to specific channels (nil = all).
func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, requireKey bool, allowedChannels ...string) ([]byte, error) {
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
@@ -149,7 +149,41 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
}
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
// Load extension metadata from config (falls back to repo-derived values).
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
element := strings.ToLower(repo.Name)
if cfg != nil && cfg.ExtensionName != "" {
element = cfg.ExtensionName
}
displayName := fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name)
if cfg != nil && cfg.DisplayName != "" {
displayName = cfg.DisplayName
}
extType := "component"
if cfg != nil && cfg.ExtensionType != "" {
extType = cfg.ExtensionType
}
maintainer := repo.Owner.Name
if cfg != nil && cfg.Maintainer != "" {
maintainer = cfg.Maintainer
}
maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
if cfg != nil && cfg.MaintainerURL != "" {
maintainerURL = cfg.MaintainerURL
}
targetVersion := ".*"
if cfg != nil && cfg.TargetVersion != "" {
targetVersion = cfg.TargetVersion
}
phpMinimum := ""
if cfg != nil && cfg.PHPMinimum != "" {
phpMinimum = cfg.PHPMinimum
}
feedDescription := ""
if cfg != nil && cfg.Description != "" {
feedDescription = cfg.Description
}
// Resolve effective streams (repo override → org default → Joomla default).
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
@@ -215,30 +249,41 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
version = version + suffix
}
desc := feedDescription
if desc == "" {
desc = fmt.Sprintf("%s %s build.", displayName, ch)
}
infoURL := fmt.Sprintf("%s/releases/tag/%s", repoLink, rel.TagName)
if cfg != nil && cfg.InfoURL != "" {
infoURL = cfg.InfoURL
}
u := xmlUpdate{
Name: fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name),
Description: fmt.Sprintf("%s - %s %s build.", repo.Owner.Name, repo.Name, ch),
Name: displayName,
Description: desc,
Element: element,
Type: "component",
Type: extType,
Client: "site",
Version: version,
CreationDate: time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02"),
InfoURL: xmlInfoURL{
Title: fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name),
URL: fmt.Sprintf("%s/releases/tag/%s", repoLink, rel.TagName),
Title: displayName,
URL: infoURL,
},
Downloads: xmlDownloads{
DownloadURL: []xmlDownloadURL{
{Type: "full", Format: "zip", URL: downloadURL},
},
},
Tags: xmlTags{Tag: ch},
Tags: xmlTags{Tag: ch},
ChangelogURL: fmt.Sprintf("%s/raw/branch/%s/CHANGELOG.md", repoLink, repo.DefaultBranch),
Maintainer: repo.Owner.Name,
MaintainerURL: fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name),
Maintainer: maintainer,
MaintainerURL: maintainerURL,
PHPMinimum: phpMinimum,
TargetPlatform: xmlTargetPlat{
Name: "joomla",
Version: ".*",
Version: targetVersion,
},
}
+80 -1
View File
@@ -29,7 +29,86 @@
<div class="ui divider"></div>
{{/* Section 2: Update Streams */}}
{{/* Section 2: Extension Metadata */}}
<h5>{{svg "octicon-package" 14}} {{ctx.Locale.Tr "org.settings.extension_metadata"}}</h5>
<p>{{ctx.Locale.Tr "org.settings.extension_metadata_desc"}}</p>
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.update_platform"}}</label>
<select name="platform" class="ui dropdown">
<option value="joomla" {{if or (eq .StreamConfig.Platform "") (eq .StreamConfig.Platform "joomla")}}selected{{end}}>Joomla</option>
<option value="dolibarr" {{if eq .StreamConfig.Platform "dolibarr"}}selected{{end}}>Dolibarr</option>
<option value="wordpress" {{if eq .StreamConfig.Platform "wordpress"}}selected{{end}}>WordPress</option>
<option value="prestashop" {{if eq .StreamConfig.Platform "prestashop"}}selected{{end}}>PrestaShop</option>
<option value="drupal" {{if eq .StreamConfig.Platform "drupal"}}selected{{end}}>Drupal</option>
<option value="composer" {{if eq .StreamConfig.Platform "composer"}}selected{{end}}>Composer (PHP)</option>
<option value="both" {{if eq .StreamConfig.Platform "both"}}selected{{end}}>Joomla + Dolibarr</option>
</select>
</div>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.extension_name"}}</label>
<input name="extension_name" value="{{.StreamConfig.ExtensionName}}" placeholder="pkg_mokowaas">
<p class="help">{{ctx.Locale.Tr "org.settings.extension_name_help"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.display_name"}}</label>
<input name="display_name" value="{{.StreamConfig.DisplayName}}" placeholder="Package - MokoWaaS">
<p class="help">{{ctx.Locale.Tr "org.settings.display_name_help"}}</p>
</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.description"}}</label>
<input name="feed_description" value="{{.StreamConfig.Description}}" placeholder="MokoWaaS stable build.">
</div>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.extension_type"}}</label>
<select name="extension_type" class="ui dropdown">
<option value="package" {{if eq .StreamConfig.ExtensionType "package"}}selected{{end}}>Package</option>
<option value="component" {{if or (eq .StreamConfig.ExtensionType "") (eq .StreamConfig.ExtensionType "component")}}selected{{end}}>Component</option>
<option value="module" {{if eq .StreamConfig.ExtensionType "module"}}selected{{end}}>Module</option>
<option value="plugin" {{if eq .StreamConfig.ExtensionType "plugin"}}selected{{end}}>Plugin</option>
<option value="template" {{if eq .StreamConfig.ExtensionType "template"}}selected{{end}}>Template</option>
<option value="library" {{if eq .StreamConfig.ExtensionType "library"}}selected{{end}}>Library</option>
</select>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.target_version"}}</label>
<input name="target_version" value="{{.StreamConfig.TargetVersion}}" placeholder="(5|6)\..*">
<p class="help">{{ctx.Locale.Tr "org.settings.target_version_help"}}</p>
</div>
</div>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.maintainer"}}</label>
<input name="maintainer" value="{{.StreamConfig.Maintainer}}" placeholder="Moko Consulting">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.maintainer_url"}}</label>
<input name="maintainer_url" value="{{.StreamConfig.MaintainerURL}}" placeholder="https://mokoconsulting.tech">
</div>
</div>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.info_url"}}</label>
<input name="info_url" value="{{.StreamConfig.InfoURL}}" placeholder="https://mokoconsulting.tech/products/mokowaas">
<p class="help">{{ctx.Locale.Tr "org.settings.info_url_help"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.php_minimum"}}</label>
<input name="php_minimum" value="{{.StreamConfig.PHPMinimum}}" placeholder="8.1">
</div>
</div>
<div class="ui divider"></div>
{{/* Section 3: Update Streams */}}
<h5>{{svg "octicon-rss" 14}} {{ctx.Locale.Tr "org.settings.update_streams_heading"}}</h5>
<p>{{ctx.Locale.Tr "org.settings.update_streams_desc"}}</p>
+1 -1
View File
@@ -128,7 +128,7 @@
</a>
{{end}}
{{if or .EnableLicenses .IsRepoAdmin}}
{{if .LicensingEnabled}}
<a href="{{.RepoLink}}/licenses" class="{{if .IsLicensesPage}}active {{end}}item">
{{svg "octicon-key"}} {{ctx.Locale.Tr "repo.licenses"}}
{{if .NumLicensePackages}}
+1 -2
View File
@@ -1,8 +1,7 @@
<div class="field {{if not .item.VisibleOnForm}}tw-hidden{{end}}">
{{template "repo/issue/fields/header" .}}
{{/* FIXME: required validation */}}
<div class="ui fluid selection dropdown {{if .item.Attributes.multiple}}multiple clearable{{end}}">
<input type="hidden" name="form-field-{{.item.ID}}" value="{{.item.Attributes.default}}">
<input type="hidden" name="form-field-{{.item.ID}}" value="{{.item.Attributes.default}}" {{if .item.Validations.required}}required{{end}}>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
{{if not .item.Validations.required}}
{{svg "octicon-x" 14 "remove icon"}}
@@ -29,12 +29,11 @@ test('markdownHandleIndention', () => {
input = input.replaceAll('|', '');
const ret = markdownHandleIndention({value: input, selStart: inputPos, selEnd: inputPos});
if (expected === null) {
expect(ret).toEqual({handled: false});
expect(ret).toEqual({});
} else {
const expectedPos = expected.indexOf('|');
expected = expected.replaceAll('|', '');
expect(ret).toEqual({
handled: true,
valueSelection: {value: expected, selStart: expectedPos, selEnd: expectedPos},
});
}
+3 -4
View File
@@ -73,7 +73,6 @@ function handleIndentSelection(textarea: HTMLTextAreaElement, e: KeyboardEvent)
}
type MarkdownHandleIndentionResult = {
handled: boolean;
valueSelection?: TextareaValueSelection;
};
@@ -135,7 +134,7 @@ function recalculateLengthBeforeLine(linesBuf: TextLinesBuffer) {
}
export function markdownHandleIndention(tvs: TextareaValueSelection): MarkdownHandleIndentionResult {
const unhandled: MarkdownHandleIndentionResult = {handled: false};
const unhandled: MarkdownHandleIndentionResult = {};
if (tvs.selEnd !== tvs.selStart) return unhandled; // do not process when there is a selection
const linesBuf = textareaSplitLines(tvs.value, tvs.selStart);
@@ -182,13 +181,13 @@ export function markdownHandleIndention(tvs: TextareaValueSelection): MarkdownHa
markdownReformatListNumbers(linesBuf, indention);
const newPos = linesBuf.lengthBeforePosLine + linesBuf.inlinePos;
return {handled: true, valueSelection: {value: linesBuf.lines.join('\n'), selStart: newPos, selEnd: newPos}};
return {valueSelection: {value: linesBuf.lines.join('\n'), selStart: newPos, selEnd: newPos}};
}
function handleNewline(textarea: HTMLTextAreaElement, e: KeyboardEvent) {
if (e.isComposing) return;
const ret = markdownHandleIndention({value: textarea.value, selStart: textarea.selectionStart, selEnd: textarea.selectionEnd});
if (!ret.handled || !ret.valueSelection) return; // FIXME: the "handled" seems redundant, only valueSelection is enough (null for unhandled)
if (!ret.valueSelection) return;
e.preventDefault();
textarea.value = ret.valueSelection.value;
textarea.setSelectionRange(ret.valueSelection.selStart, ret.valueSelection.selEnd);