Add the Create Channel dialog
The official client creates and edits channels with one dialog, so ChannelEditDialog becomes ChannelDialog with two entry points. Creating starts from caller-chosen defaults instead of a channelinfo and sends a channelcreate carrying only what deviates from a plain new channel — TeamSpeak checks a create permission per property that is present. The server row now has a context menu with "Create Channel" and "Create Spacer" (pre-filled with the lowest free [spacerN], since channel names must be unique), and a channel's menu offers "Create Channel" and "Create Sub-Channel", both starting out temporary. Verified against the test server: a temporary channel is created, and a permanent one a guest may not create reports the server's refusal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,31 @@ final class ChannelAdmin {
|
||||
return ChannelSettings.from(channelId, info.getMap());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a channel below {@code parentId} (0 being the top level).
|
||||
*
|
||||
* @param properties the new channel's properties, as {@link ChannelSettings#creationParameters}
|
||||
* @return the new channel's id
|
||||
*/
|
||||
int create(int parentId, Map<String, String> properties) throws Exception {
|
||||
SingleCommand cmd = new SingleCommand("channelcreate", ProtocolRole.CLIENT);
|
||||
cmd.add(new CommandSingleParameter("cpid", Integer.toString(parentId)));
|
||||
for (Map.Entry<String, String> property : properties.entrySet()) {
|
||||
cmd.add(new CommandSingleParameter(property.getKey(), property.getValue()));
|
||||
}
|
||||
for (SingleCommand answer : conn.socket().executeCommand(cmd).get()) {
|
||||
int cid = parseInt(answer.toMap().get("cid"), -1);
|
||||
if (cid > 0) return cid;
|
||||
}
|
||||
// Some servers acknowledge the command without naming the channel; the
|
||||
// notifychannelcreated they also send does name it, so read it off the model
|
||||
// once that event has been handled.
|
||||
conn.awaitEventsProcessed();
|
||||
ChannelNode created = conn.getModel().findChannelByName(parentId, properties.get("channel_name"));
|
||||
if (created == null) throw new IllegalStateException("The server did not report the new channel");
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/** Applies {@code changes} (property name to value) with a single {@code channeledit}. */
|
||||
void edit(int channelId, Map<String, String> changes) throws Exception {
|
||||
if (changes.isEmpty()) return;
|
||||
|
||||
@@ -143,6 +143,32 @@ public final class ChannelSettings {
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@code channelcreate} parameters for a channel that does not exist yet.
|
||||
*
|
||||
* <p>Everything a fresh channel would get anyway is left out, because TeamSpeak checks
|
||||
* a create permission per property that is present — a client allowed to create plain
|
||||
* channels but not, say, ones with a topic must not send an empty topic. What the
|
||||
* server cannot infer is always sent: the name, the channel type (whose absent flags
|
||||
* would silently mean temporary), the codec, and the client limits when they are not
|
||||
* unlimited.
|
||||
*/
|
||||
public Map<String, String> creationParameters() {
|
||||
Map<String, String> params = changesFrom(new ChannelSettings());
|
||||
params.put("channel_name", name);
|
||||
params.put("channel_flag_permanent", flag(type == Type.PERMANENT));
|
||||
params.put("channel_flag_semi_permanent", flag(type == Type.SEMI_PERMANENT));
|
||||
params.put("channel_flag_temporary", flag(type == Type.TEMPORARY));
|
||||
params.put("channel_codec", Integer.toString(codec));
|
||||
params.put("channel_codec_quality", Integer.toString(codecQuality));
|
||||
if (!maxClientsUnlimited) params.put("channel_maxclients", Integer.toString(maxClients));
|
||||
if (!familyInherited && !familyUnlimited) {
|
||||
params.put("channel_maxfamilyclients", Integer.toString(maxFamilyClients));
|
||||
}
|
||||
if (password == null || password.isEmpty()) params.remove("channel_password");
|
||||
return params;
|
||||
}
|
||||
|
||||
private static void putIfChanged(Map<String, String> into, String key, String value, String was) {
|
||||
if (!value.equals(was)) into.put(key, value);
|
||||
}
|
||||
|
||||
@@ -218,17 +218,34 @@ public final class ServerModel {
|
||||
public synchronized List<ChannelNode> siblingsOf(int channelId) {
|
||||
ChannelNode channel = channels.get(channelId);
|
||||
if (channel == null) return new ArrayList<>();
|
||||
List<ChannelNode> siblings = new ArrayList<>();
|
||||
for (ChannelNode c : channels.values()) {
|
||||
if (c.parentId == channel.parentId) siblings.add(c);
|
||||
}
|
||||
// Ordered with the channel still in place: the order links form a chain, and
|
||||
// pulling a link out of it first would strand everything below.
|
||||
sortSiblings(siblings);
|
||||
List<ChannelNode> siblings = childrenOf(channel.parentId);
|
||||
siblings.remove(channel);
|
||||
return siblings;
|
||||
}
|
||||
|
||||
/**
|
||||
* The channels directly below {@code parentId} (0 being the top level), in the order
|
||||
* they appear in the tree — what a new channel can be sorted after.
|
||||
*/
|
||||
public synchronized List<ChannelNode> childrenOf(int parentId) {
|
||||
List<ChannelNode> children = new ArrayList<>();
|
||||
for (ChannelNode c : channels.values()) {
|
||||
if (c.parentId == parentId) children.add(c);
|
||||
}
|
||||
sortSiblings(children);
|
||||
return children;
|
||||
}
|
||||
|
||||
/** The channel of that exact name directly below {@code parentId}, or {@code null}. */
|
||||
public synchronized ChannelNode findChannelByName(int parentId, String name) {
|
||||
for (ChannelNode c : channels.values()) {
|
||||
if (c.parentId == parentId && c.name.equals(name)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The "/"-separated path of a channel from the root, e.g. {@code "Lobby/Games"}. */
|
||||
public synchronized String channelPath(int id) {
|
||||
ChannelNode c = channels.get(id);
|
||||
|
||||
@@ -923,6 +923,29 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a channel below {@code parentId} (0 being the top level) and gives it the
|
||||
* channel permissions it was created with.
|
||||
*
|
||||
* @param callback given the new channel's id, or the failure message. A channel whose
|
||||
* permissions could not be set still exists, and is reported as an error
|
||||
* saying so.
|
||||
*/
|
||||
public void createChannel(int parentId, Map<String, String> properties,
|
||||
Map<String, Integer> permissions,
|
||||
BiConsumer<Integer, String> callback) {
|
||||
run("ts3j-channel-create", callback, () -> {
|
||||
int channelId = channels.create(parentId, properties);
|
||||
try {
|
||||
channels.writePermissions(channelId, permissions, List.of());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("The channel was created, but its permissions"
|
||||
+ " could not be set: " + rootMessage(e));
|
||||
}
|
||||
return channelId;
|
||||
});
|
||||
}
|
||||
|
||||
/** The ids of the icons uploaded to this virtual server. */
|
||||
public void requestServerIcons(BiConsumer<List<Long>, String> callback) {
|
||||
run("ts3j-icon-list", callback, channels::listIcons);
|
||||
|
||||
@@ -23,7 +23,7 @@ import java.awt.Insets;
|
||||
* The channel editor's "Advanced" tab: the phonetic name, the delete delay of a temporary
|
||||
* channel, voice encryption, and the two client limits.
|
||||
*/
|
||||
final class ChannelAdvancedPanel extends JPanel implements ChannelEditDialog.Tab {
|
||||
final class ChannelAdvancedPanel extends JPanel implements ChannelDialog.Tab {
|
||||
|
||||
/** TeamSpeak's ceiling for {@code channel_delete_delay}, in seconds. */
|
||||
private static final int MAX_DELETE_DELAY = 604800;
|
||||
|
||||
@@ -20,7 +20,7 @@ import java.awt.Insets;
|
||||
* The channel editor's "Audio" tab: the codec preset, or the codec and quality picked by
|
||||
* hand, with the bandwidth one talking client then costs.
|
||||
*/
|
||||
final class ChannelAudioPanel extends JPanel implements ChannelEditDialog.Tab {
|
||||
final class ChannelAudioPanel extends JPanel implements ChannelDialog.Tab {
|
||||
|
||||
/** Codec names in TeamSpeak's own id order, which is what the combo box index is. */
|
||||
private static final String[] CODEC_NAMES = {
|
||||
|
||||
@@ -35,15 +35,20 @@ import java.util.Map;
|
||||
|
||||
/**
|
||||
* TeamSpeak's channel editor: the channel's name, icon, password, topic and description
|
||||
* above four tabs holding everything else.
|
||||
* above four tabs holding everything else. The official client uses the same dialog to
|
||||
* create a channel and to edit one, and so does this.
|
||||
*
|
||||
* <p>The channel's properties are not in the tree model — the tree only carries what the
|
||||
* server pushes for display — so the dialog opens empty and fills itself from a
|
||||
* {@code channelinfo}, with its own permissions arriving separately. Saving sends only what
|
||||
* was actually changed, because a {@code channeledit} carrying one property the client may
|
||||
* not modify is refused as a whole.
|
||||
* <p>When editing, the channel's properties are not in the tree model — the tree only
|
||||
* carries what the server pushes for display — so the dialog opens empty and fills itself
|
||||
* from a {@code channelinfo}, with its own permissions arriving separately. Saving sends
|
||||
* only what was actually changed, because a {@code channeledit} carrying one property the
|
||||
* client may not modify is refused as a whole.
|
||||
*
|
||||
* <p>When creating, there is nothing to read: the dialog starts from the defaults its
|
||||
* caller chose and sends a {@code channelcreate} carrying whatever deviates from a plain
|
||||
* new channel, for the same reason.
|
||||
*/
|
||||
final class ChannelEditDialog extends JDialog {
|
||||
final class ChannelDialog extends JDialog {
|
||||
|
||||
/** A tab that maps a part of the channel's settings onto its controls. */
|
||||
interface Tab {
|
||||
@@ -66,13 +71,19 @@ final class ChannelEditDialog extends JDialog {
|
||||
+ " (r=right, c=center, l=left),</td></tr>"
|
||||
+ "<tr><td style='white-space:nowrap'>\"*\" will repeat the text to fill the whole line.</td></tr>"
|
||||
+ "<tr><td style='white-space:nowrap'>Change \"#\" to get a unique channel name.</td></tr>"
|
||||
+ "<tr><td style='white-space:nowrap'>[lSpacer0] a left aligned text</td></tr>"
|
||||
+ "<tr><td style='white-space:nowrap'>[cSpacer1] a centered text</td></tr>"
|
||||
+ "<tr><td style='white-space:nowrap'>[rSpacer2] a right aligned text</td></tr>"
|
||||
+ "<tr><td style='white-space:nowrap'>Use one of the three-character-blocks as text for a"
|
||||
+ " special spacer: \"---\", \"...\", \"-.-\", \"___\", \"-..\"</td></tr>"
|
||||
+ "</table></html>";
|
||||
|
||||
private final TeamspeakConnection conn;
|
||||
private final GroupIcons groupIcons;
|
||||
/** The channel being edited, or {@code null} when a new one is being created. */
|
||||
private final ChannelNode channel;
|
||||
/** Where a new channel goes; 0 is the top level. Unused when editing. */
|
||||
private final int parentId;
|
||||
|
||||
private final JTextField name = new JTextField();
|
||||
private final JPasswordField password = new JPasswordField();
|
||||
@@ -94,12 +105,33 @@ final class ChannelEditDialog extends JDialog {
|
||||
private long iconId;
|
||||
private boolean passwordEdited;
|
||||
|
||||
ChannelEditDialog(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, ChannelNode channel) {
|
||||
super(owner, "Edit Channel: " + channel.name, ModalityType.APPLICATION_MODAL);
|
||||
/** The editor for an existing channel. */
|
||||
static ChannelDialog toEdit(Window owner, TeamspeakConnection conn, GroupIcons groupIcons,
|
||||
ChannelNode channel) {
|
||||
return new ChannelDialog(owner, conn, groupIcons, "Edit Channel: " + channel.name,
|
||||
channel, channel.parentId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor for a channel that does not exist yet.
|
||||
*
|
||||
* @param parentId the channel it goes below, 0 being the top level
|
||||
* @param defaults what the dialog opens with, e.g. the channel type its caller prefers
|
||||
*/
|
||||
static ChannelDialog toCreate(Window owner, TeamspeakConnection conn, GroupIcons groupIcons,
|
||||
String title, int parentId, ChannelSettings defaults) {
|
||||
return new ChannelDialog(owner, conn, groupIcons, title, null, parentId, defaults);
|
||||
}
|
||||
|
||||
private ChannelDialog(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, String title,
|
||||
ChannelNode channel, int parentId, ChannelSettings defaults) {
|
||||
super(owner, title, ModalityType.APPLICATION_MODAL);
|
||||
this.conn = conn;
|
||||
this.groupIcons = groupIcons;
|
||||
this.channel = channel;
|
||||
this.standardPanel = new ChannelStandardPanel(conn.getModel().siblingsOf(channel.id));
|
||||
this.parentId = parentId;
|
||||
this.standardPanel = new ChannelStandardPanel(channel != null
|
||||
? conn.getModel().siblingsOf(channel.id) : conn.getModel().childrenOf(parentId));
|
||||
|
||||
standardPanel.onTypeChanged(advancedPanel::setChannelType);
|
||||
settingsTabs = List.of(standardPanel, audioPanel, advancedPanel);
|
||||
@@ -122,7 +154,8 @@ final class ChannelEditDialog extends JDialog {
|
||||
setMinimumSize(new Dimension(480, 520));
|
||||
setLocationRelativeTo(owner);
|
||||
|
||||
load();
|
||||
if (channel != null) load();
|
||||
else prepareNew(defaults);
|
||||
}
|
||||
|
||||
// ---- layout ----
|
||||
@@ -261,6 +294,16 @@ final class ChannelEditDialog extends JDialog {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the dialog on a channel that does not exist yet. Its permissions start empty
|
||||
* and editable — they are applied to the channel once the server has created it.
|
||||
*/
|
||||
private void prepareNew(ChannelSettings defaults) {
|
||||
permissionsPanel.read(Map.of());
|
||||
apply(defaults);
|
||||
name.selectAll();
|
||||
}
|
||||
|
||||
private void apply(ChannelSettings settings) {
|
||||
original = settings;
|
||||
name.setText(settings.name);
|
||||
@@ -351,6 +394,14 @@ final class ChannelEditDialog extends JDialog {
|
||||
return;
|
||||
}
|
||||
|
||||
if (channel == null) {
|
||||
ok.setEnabled(false);
|
||||
conn.createChannel(parentId, edited.creationParameters(), permissionsPanel.changed(),
|
||||
(channelId, error) -> SwingUtilities.invokeLater(
|
||||
() -> finish(error, "Could not create the channel: ")));
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, String> changes = edited.changesFrom(original);
|
||||
Map<String, Integer> setPermissions = permissionsPanel.changed();
|
||||
List<String> clearedPermissions = permissionsPanel.cleared();
|
||||
@@ -361,14 +412,16 @@ final class ChannelEditDialog extends JDialog {
|
||||
|
||||
ok.setEnabled(false);
|
||||
conn.applyChannelEdit(channel.id, changes, setPermissions, clearedPermissions,
|
||||
error -> SwingUtilities.invokeLater(() -> {
|
||||
if (error == null) {
|
||||
dispose();
|
||||
return;
|
||||
}
|
||||
ok.setEnabled(true);
|
||||
JOptionPane.showMessageDialog(this, "Could not save the channel: " + error,
|
||||
"Error", JOptionPane.ERROR_MESSAGE);
|
||||
}));
|
||||
error -> SwingUtilities.invokeLater(() -> finish(error, "Could not save the channel: ")));
|
||||
}
|
||||
|
||||
/** Closes the dialog, or reports why the server would not have it and lets the user retry. */
|
||||
private void finish(String error, String errorPrefix) {
|
||||
if (error == null) {
|
||||
dispose();
|
||||
return;
|
||||
}
|
||||
ok.setEnabled(true);
|
||||
JOptionPane.showMessageDialog(this, errorPrefix + error, "Error", JOptionPane.ERROR_MESSAGE);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ChannelSettings;
|
||||
|
||||
import javax.swing.JMenuItem;
|
||||
import javax.swing.JPopupMenu;
|
||||
@@ -20,6 +21,14 @@ final class ChannelMenu {
|
||||
join.addActionListener(a -> actions.joinChannel(channel.id));
|
||||
menu.add(join);
|
||||
menu.addSeparator();
|
||||
// Both start out temporary: a channel made from another channel's menu is
|
||||
// usually a throwaway one, and a permanent channel is a deliberate choice.
|
||||
JMenuItem create = new JMenuItem("Create Channel", Icons.of("CHANNEL_CREATE"));
|
||||
create.addActionListener(a -> actions.createChannel(null, ChannelSettings.Type.TEMPORARY));
|
||||
menu.add(create);
|
||||
JMenuItem createSub = new JMenuItem("Create Sub-Channel", Icons.of("CHANNEL_CREATE_SUB"));
|
||||
createSub.addActionListener(a -> actions.createChannel(channel, ChannelSettings.Type.TEMPORARY));
|
||||
menu.add(createSub);
|
||||
JMenuItem edit = new JMenuItem("Edit Channel", Icons.of("CHANNEL_EDIT"));
|
||||
edit.addActionListener(a -> actions.editChannel(channel));
|
||||
menu.add(edit);
|
||||
|
||||
@@ -29,7 +29,7 @@ import java.util.function.Consumer;
|
||||
* The channel editor's "Standard" tab: how long the channel lives, whether it is the
|
||||
* server's default one, where it sorts among its siblings, and its moderation setting.
|
||||
*/
|
||||
final class ChannelStandardPanel extends JPanel implements ChannelEditDialog.Tab {
|
||||
final class ChannelStandardPanel extends JPanel implements ChannelDialog.Tab {
|
||||
|
||||
private final JRadioButton temporary = new JRadioButton("Temporary");
|
||||
private final JRadioButton semiPermanent = new JRadioButton("Semi-Permanent");
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelSettings;
|
||||
|
||||
import javax.swing.JMenuItem;
|
||||
import javax.swing.JPopupMenu;
|
||||
|
||||
/**
|
||||
* The context menu for the server itself — the tree's root row.
|
||||
*/
|
||||
final class ServerMenu {
|
||||
|
||||
private ServerMenu() {
|
||||
}
|
||||
|
||||
static JPopupMenu build(ServerTreePanel.Actions actions) {
|
||||
JPopupMenu menu = new JPopupMenu();
|
||||
JMenuItem create = new JMenuItem("Create Channel", Icons.of("CHANNEL_CREATE"));
|
||||
create.addActionListener(a -> actions.createChannel(null, ChannelSettings.Type.PERMANENT));
|
||||
menu.add(create);
|
||||
JMenuItem spacer = new JMenuItem("Create Spacer",
|
||||
Icons.ofAny("CHANNEL_CREATE_SPACER", "CHANNEL_CREATE"));
|
||||
spacer.setToolTipText("Create a new spacer");
|
||||
spacer.addActionListener(a -> actions.createSpacer());
|
||||
menu.add(spacer);
|
||||
return menu;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ChannelSettings;
|
||||
import com.ts3client.net.ClientEntry;
|
||||
import com.ts3client.net.Group;
|
||||
import com.ts3client.net.TeamspeakConnection;
|
||||
@@ -208,7 +209,33 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions {
|
||||
@Override
|
||||
public void editChannel(ChannelNode channel) {
|
||||
if (!conn.isConnected()) return;
|
||||
new ChannelEditDialog(host, conn, groupIcons, channel).setVisible(true);
|
||||
ChannelDialog.toEdit(host, conn, groupIcons, channel).setVisible(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void createChannel(ChannelNode parent, ChannelSettings.Type type) {
|
||||
if (!conn.isConnected()) return;
|
||||
ChannelSettings defaults = new ChannelSettings();
|
||||
defaults.type = type;
|
||||
ChannelDialog.toCreate(host, conn, groupIcons,
|
||||
parent == null ? "Create Channel" : "Create Sub-Channel of " + parent.name,
|
||||
parent == null ? 0 : parent.id, defaults).setVisible(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void createSpacer() {
|
||||
if (!conn.isConnected()) return;
|
||||
ChannelSettings defaults = new ChannelSettings();
|
||||
// Spacers are cosmetic root channels, so they outlive everyone and, being channels,
|
||||
// need a name no other channel has — which is what the tag's number is for.
|
||||
defaults.name = Spacers.freeName(conn.getModel().childrenOf(0));
|
||||
ChannelDialog.toCreate(host, conn, groupIcons, "Create Spacer", 0, defaults)
|
||||
.setVisible(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isConnected() {
|
||||
return conn.isConnected();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ChannelSettings;
|
||||
import com.ts3client.net.ClientEntry;
|
||||
import com.ts3client.net.Group;
|
||||
import com.ts3client.net.ServerModel;
|
||||
@@ -47,6 +48,20 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
/** Opens the channel editor for a channel. */
|
||||
void editChannel(ChannelNode channel);
|
||||
|
||||
/**
|
||||
* Opens the channel creator.
|
||||
*
|
||||
* @param parent the channel the new one goes below, or {@code null} for a top-level one
|
||||
* @param type the channel type to start with
|
||||
*/
|
||||
void createChannel(ChannelNode parent, ChannelSettings.Type type);
|
||||
|
||||
/** Opens the channel creator pre-filled as a spacer, which is always top-level. */
|
||||
void createSpacer();
|
||||
|
||||
/** Whether the server is there to act on at all. */
|
||||
boolean isConnected();
|
||||
|
||||
/** Open the file repository browser for a channel. */
|
||||
void browseFiles(ChannelNode channel);
|
||||
|
||||
@@ -196,6 +211,9 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
showClientMenu((ClientEntry) obj, e);
|
||||
} else if (obj instanceof ChannelNode) {
|
||||
showChannelMenu((ChannelNode) obj, e);
|
||||
} else if (path.getPathCount() == 1 && actions.isConnected()) {
|
||||
// The root row is the server itself, whose user object is just its name.
|
||||
ServerMenu.build(actions).show(tree, e.getX(), e.getY());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelNode;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -21,6 +26,9 @@ public final class Spacers {
|
||||
}
|
||||
}
|
||||
|
||||
/** The tag's identifying part, which is what keeps two spacers' names apart. */
|
||||
private static final Pattern TAG = Pattern.compile("^\\[(?:\\*|[lcr])?spacer([^\\]]*)\\]");
|
||||
|
||||
private static final Pattern PATTERN =
|
||||
Pattern.compile("^\\[(\\*|[lcr])?spacer[^\\]]*\\](.*)$");
|
||||
|
||||
@@ -41,6 +49,22 @@ public final class Spacers {
|
||||
return parse(channelName) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A spacer name no channel among {@code siblings} is using: {@code [spacerN]} with the
|
||||
* lowest free N. TeamSpeak needs channel names to be unique, and the number in the tag
|
||||
* is how spacers — which usually all read the same — get away with it.
|
||||
*/
|
||||
public static String freeName(List<ChannelNode> siblings) {
|
||||
Set<String> taken = new HashSet<>();
|
||||
for (ChannelNode sibling : siblings) {
|
||||
Matcher m = TAG.matcher(sibling.name == null ? "" : sibling.name);
|
||||
if (m.find()) taken.add(m.group(1));
|
||||
}
|
||||
for (int n = 0; ; n++) {
|
||||
if (taken.add(Integer.toString(n))) return "[spacer" + n + "]";
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the visible label for a spacer at roughly the given character width. */
|
||||
public static String render(Spacer s, int width) {
|
||||
String caption = s.caption == null ? "" : s.caption;
|
||||
|
||||
Reference in New Issue
Block a user