using System; using System.Collections.Generic; using System.Drawing; using System.Windows.Forms; namespace Clipman { /// /// Windows editor for the sync rules document (sync-rules-spec.md sections 3-5). Mutates a /// working copy of the document and calls only on OK. /// internal sealed class SyncRulesForm : Form { private readonly ClipStore store; private readonly string deviceName; private readonly bool readOnly; private readonly SyncRulesDocument document; private readonly CheckBox enabledBox; private readonly ListView channelsList; private readonly Button addChannelButton; private readonly Button editChannelButton; private readonly Button removeChannelButton; private readonly Label channelStatusLabel; private readonly ListView devicesList; private readonly Button editSubscriptionsButton; private readonly Button okButton; private readonly Button cancelButton; public bool Applied { get; private set; } public SyncRulesForm(ClipStore store, string deviceName) { this.store = store; this.deviceName = (deviceName ?? string.Empty).Trim(); readOnly = store.SyncRulesReadOnly(); document = store.GetSyncRules() ?? new SyncRulesDocument(); Text = "Sync Rules"; StartPosition = FormStartPosition.CenterParent; FormBorderStyle = FormBorderStyle.FixedDialog; MinimizeBox = false; MaximizeBox = false; ShowInTaskbar = false; ClientSize = new Size(616, 570); KeyPreview = true; enabledBox = new CheckBox { Text = "&Enable sync rules", Location = new Point(12, 12), AutoSize = true, Checked = document.Enabled, AccessibleName = "Enable sync rules", AccessibleDescription = "When checked, clipboard history can be split into named channels so only the devices you choose receive each channel." }; Controls.Add(enabledBox); var warningLabel = new Label { Text = "Enable sync rules only after every device runs a Clipman version that supports them. Older devices will continue to sync the main history only.", Location = new Point(12, 38), Size = new Size(592, 36), AccessibleName = "Compatibility warning" }; Controls.Add(warningLabel); var readOnlyLabel = new Label { Text = "These rules were created by a newer Clipman version and can be viewed but not changed here.", Location = new Point(12, 78), Size = new Size(592, 32), Visible = readOnly, AccessibleName = "Read-only notice" }; Controls.Add(readOnlyLabel); var channelsGroup = new GroupBox { Text = "C&hannels", Location = new Point(12, 114), Size = new Size(592, 230) }; channelsList = new ListView { Location = new Point(12, 22), Size = new Size(462, 172), View = View.Details, FullRowSelect = true, HideSelection = false, MultiSelect = false, AccessibleName = "Sync channels", AccessibleDescription = "Named channels and their routing rule. Press Enter or double-click a channel to edit it." }; channelsList.Columns.Add("Name", 190); channelsList.Columns.Add("Rule", 258); channelsList.SelectedIndexChanged += (s, e) => UpdateChannelButtonStates(); channelsList.DoubleClick += (s, e) => EditSelectedChannel(); channelsList.KeyDown += ChannelsListKeyDown; channelsGroup.Controls.Add(channelsList); addChannelButton = new Button { Text = "&Add...", Location = new Point(486, 22), Size = new Size(94, 28), AccessibleName = "Add channel", AccessibleDescription = "Opens the channel editor to create a new sync channel." }; addChannelButton.Click += (s, e) => AddChannel(); channelsGroup.Controls.Add(addChannelButton); editChannelButton = new Button { Text = "Ed&it...", Location = new Point(486, 58), Size = new Size(94, 28), AccessibleName = "Edit channel", AccessibleDescription = "Opens the channel editor for the selected channel." }; editChannelButton.Click += (s, e) => EditSelectedChannel(); channelsGroup.Controls.Add(editChannelButton); removeChannelButton = new Button { Text = "&Remove", Location = new Point(486, 94), Size = new Size(94, 28), AccessibleName = "Remove channel" }; removeChannelButton.Click += (s, e) => RemoveSelectedChannel(); channelsGroup.Controls.Add(removeChannelButton); channelStatusLabel = new Label { Location = new Point(12, 198), Size = new Size(462, 28), AccessibleName = "Remove channel status" }; channelsGroup.Controls.Add(channelStatusLabel); Controls.Add(channelsGroup); var devicesGroup = new GroupBox { Text = "&Devices", Location = new Point(12, 352), Size = new Size(592, 168) }; devicesList = new ListView { Location = new Point(12, 22), Size = new Size(462, 132), View = View.Details, FullRowSelect = true, HideSelection = false, MultiSelect = false, AccessibleName = "Devices", AccessibleDescription = "Devices and the channels each one receives. Press Enter or double-click a device to edit its subscriptions." }; devicesList.Columns.Add("Device", 220); devicesList.Columns.Add("Receives", 220); devicesList.SelectedIndexChanged += (s, e) => UpdateDeviceButtonStates(); devicesList.DoubleClick += (s, e) => EditSelectedDeviceSubscriptions(); devicesList.KeyDown += DevicesListKeyDown; devicesGroup.Controls.Add(devicesList); editSubscriptionsButton = new Button { Text = "Edit s&ubscriptions...", Location = new Point(486, 22), Size = new Size(94, 46), AccessibleName = "Edit subscriptions", AccessibleDescription = "Opens the subscription editor for the selected device." }; editSubscriptionsButton.Click += (s, e) => EditSelectedDeviceSubscriptions(); devicesGroup.Controls.Add(editSubscriptionsButton); Controls.Add(devicesGroup); okButton = new Button { Text = "OK", Location = new Point(426, 528), Size = new Size(85, 28), DialogResult = DialogResult.None, AccessibleName = "OK" }; okButton.Click += (s, e) => AcceptChanges(); Controls.Add(okButton); cancelButton = new Button { Text = "Cancel", Location = new Point(519, 528), Size = new Size(85, 28), DialogResult = DialogResult.Cancel, AccessibleName = "Cancel" }; Controls.Add(cancelButton); AcceptButton = okButton; CancelButton = cancelButton; ContextHelp.Prepare(this, true); if (readOnly) { enabledBox.Enabled = false; addChannelButton.Enabled = false; } RefreshChannelsList(); RefreshDevicesList(); } protected override void OnShown(EventArgs e) { base.OnShown(e); if (!readOnly) { enabledBox.Focus(); } else { cancelButton.Focus(); } } protected override void OnKeyDown(KeyEventArgs e) { if (e.KeyCode == Keys.Escape) { DialogResult = DialogResult.Cancel; Close(); e.Handled = true; return; } base.OnKeyDown(e); } private void ChannelsListKeyDown(object sender, KeyEventArgs e) { if (e.KeyCode == Keys.Enter) { EditSelectedChannel(); e.Handled = true; } } private void DevicesListKeyDown(object sender, KeyEventArgs e) { if (e.KeyCode == Keys.Enter) { EditSelectedDeviceSubscriptions(); e.Handled = true; } } private void AcceptChanges() { if (readOnly) { DialogResult = DialogResult.Cancel; Close(); return; } document.Enabled = enabledBox.Checked; var error = store.SetSyncRules(document); if (error != null) { MessageBox.Show(this, error, "Clipman Sync Rules", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } Applied = true; DialogResult = DialogResult.OK; Close(); } // ----------------------------------------------------------------------------------- // Channels // ----------------------------------------------------------------------------------- private void RefreshChannelsList() { var previousKey = SelectedChannelKey(); channelsList.BeginUpdate(); try { channelsList.Items.Clear(); foreach (var channel in document.Channels) { if (channel == null) continue; var item = new ListViewItem(channel.Name ?? string.Empty); item.SubItems.Add(SyncRuleEngine.RouteSummary(channel.Route)); item.Tag = channel; channelsList.Items.Add(item); } } finally { channelsList.EndUpdate(); } var index = FindChannelIndex(previousKey); if (index < 0 && channelsList.Items.Count > 0) index = 0; if (index >= 0) { channelsList.Items[index].Selected = true; channelsList.Items[index].Focused = true; } UpdateChannelButtonStates(); } private string SelectedChannelKey() { if (channelsList.SelectedItems.Count == 0) return string.Empty; var channel = channelsList.SelectedItems[0].Tag as SyncChannel; return channel == null ? string.Empty : SyncRuleEngine.ChannelKey(channel.Name); } private int FindChannelIndex(string key) { if (string.IsNullOrEmpty(key)) return -1; for (var i = 0; i < channelsList.Items.Count; i++) { var channel = channelsList.Items[i].Tag as SyncChannel; if (channel != null && SyncRuleEngine.ChannelKey(channel.Name) == key) return i; } return -1; } private void UpdateChannelButtonStates() { var hasSelection = channelsList.SelectedItems.Count > 0; editChannelButton.Enabled = !readOnly && hasSelection; if (readOnly || !hasSelection) { removeChannelButton.Enabled = false; ContextHelp.SetDescription(removeChannelButton, "Select a channel to remove."); channelStatusLabel.Text = string.Empty; return; } var channel = (SyncChannel)channelsList.SelectedItems[0].Tag; var key = SyncRuleEngine.ChannelKey(channel.Name); var subscribed = SyncRuleEngine.SubscribedChannels(document, deviceName); var canSee = subscribed == null || subscribed.Contains(key); removeChannelButton.Enabled = canSee; if (canSee) { ContextHelp.SetDescription(removeChannelButton, "Removes the selected channel. Its entries move to the next matching channel or to the main history."); channelStatusLabel.Text = string.Empty; } else { var notice = "Remove is unavailable: this device is not subscribed to " + (channel.Name ?? key) + " and cannot see its entries. Subscribe this device to the channel first."; ContextHelp.SetDescription(removeChannelButton, notice); channelStatusLabel.Text = notice; } } private void AddChannel() { using (var dialog = new SyncChannelEditorForm(null, store.GetGroups(), store.GetDevices(), document, -1)) { if (dialog.ShowDialog(this) == DialogResult.OK) { document.Channels.Add(dialog.Result); RefreshChannelsList(); RefreshDevicesList(); } } } private void EditSelectedChannel() { if (readOnly || channelsList.SelectedItems.Count == 0) return; var channel = (SyncChannel)channelsList.SelectedItems[0].Tag; var index = document.Channels.IndexOf(channel); if (index < 0) return; using (var dialog = new SyncChannelEditorForm(channel, store.GetGroups(), store.GetDevices(), document, index)) { if (dialog.ShowDialog(this) == DialogResult.OK) { document.Channels[index] = dialog.Result; RefreshChannelsList(); RefreshDevicesList(); } } } private void RemoveSelectedChannel() { if (readOnly || channelsList.SelectedItems.Count == 0) return; var channel = (SyncChannel)channelsList.SelectedItems[0].Tag; var key = SyncRuleEngine.ChannelKey(channel.Name); var subscribed = SyncRuleEngine.SubscribedChannels(document, deviceName); if (subscribed != null && !subscribed.Contains(key)) { MessageBox.Show(this, "This device is not subscribed to the " + channel.Name + " channel and cannot see its entries. Subscribe to it before removing it.", "Clipman Sync Rules", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } var confirm = MessageBox.Show(this, "Entries in this channel will move to the next matching channel or to the main history. No entries are deleted.", "Remove Channel", MessageBoxButtons.YesNo, MessageBoxIcon.Warning); if (confirm != DialogResult.Yes) return; document.Channels.Remove(channel); foreach (var device in document.Devices) { if (device == null || device.Channels == null) continue; if (device.Channels.Count == 1 && (device.Channels[0] ?? string.Empty).Trim() == "*") continue; device.Channels.RemoveAll(candidate => string.Equals((candidate ?? string.Empty).Trim(), key, StringComparison.OrdinalIgnoreCase)); } RefreshChannelsList(); RefreshDevicesList(); } // ----------------------------------------------------------------------------------- // Devices // ----------------------------------------------------------------------------------- private void RefreshDevicesList() { var previousName = SelectedDeviceName(); var names = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var device in document.Devices) { if (device == null) continue; var name = (device.Name ?? string.Empty).Trim(); if (name.Length == 0) continue; if (seen.Add(name)) names.Add(name); } foreach (var name in store.GetDevices()) { if (seen.Add(name)) names.Add(name); } names.Sort(StringComparer.CurrentCultureIgnoreCase); devicesList.BeginUpdate(); try { devicesList.Items.Clear(); foreach (var name in names) { var device = FindDevice(name); var receives = device == null ? SyncRuleEngine.SubscriptionSummary(new List { "*" }) : SyncRuleEngine.SubscriptionSummary(device.Channels); var item = new ListViewItem(name); item.SubItems.Add(receives); item.Tag = name; devicesList.Items.Add(item); } } finally { devicesList.EndUpdate(); } var index = -1; for (var i = 0; i < names.Count; i++) { if (string.Equals(names[i], previousName, StringComparison.OrdinalIgnoreCase)) { index = i; break; } } if (index < 0 && devicesList.Items.Count > 0) index = 0; if (index >= 0) { devicesList.Items[index].Selected = true; devicesList.Items[index].Focused = true; } UpdateDeviceButtonStates(); } private SyncDevice FindDevice(string name) { foreach (var device in document.Devices) { if (device != null && string.Equals((device.Name ?? string.Empty).Trim(), name, StringComparison.OrdinalIgnoreCase)) { return device; } } return null; } private string SelectedDeviceName() { return devicesList.SelectedItems.Count == 0 ? string.Empty : Convert.ToString(devicesList.SelectedItems[0].Tag); } private void UpdateDeviceButtonStates() { editSubscriptionsButton.Enabled = !readOnly && devicesList.SelectedItems.Count > 0; } private void EditSelectedDeviceSubscriptions() { if (readOnly || devicesList.SelectedItems.Count == 0) return; var name = SelectedDeviceName(); var existing = FindDevice(name); var currentChannels = existing == null ? new List { "*" } : new List(existing.Channels ?? new List()); using (var dialog = new SyncDeviceSubscriptionForm(name, document.Channels, currentChannels)) { if (dialog.ShowDialog(this) == DialogResult.OK) { if (existing == null) { existing = new SyncDevice { Name = name }; document.Devices.Add(existing); } existing.Channels = dialog.SelectedChannels; RefreshDevicesList(); } } } } /// /// Adds or edits one sync channel. Validates the whole prospective document (this channel /// replacing or joining ) via /// before accepting, so uniqueness, reserved names, and storage-name collisions are caught here. /// internal sealed class SyncChannelEditorForm : Form { private readonly SyncRulesDocument contextDocument; private readonly int editingIndex; private readonly TextBox nameBox; private readonly CheckedListBox groupsBox; private readonly TextBox otherGroupsBox; private readonly CheckedListBox devicesBox; private readonly TextBox otherDevicesBox; private readonly CheckBox richTextImagesBox; public SyncChannel Result { get; private set; } public SyncChannelEditorForm(SyncChannel existing, List knownGroups, List knownDevices, SyncRulesDocument contextDocument, int editingIndex) { this.contextDocument = contextDocument; this.editingIndex = editingIndex; var route = existing == null ? new SyncRoute() : (existing.Route ?? new SyncRoute()); var knownGroupList = knownGroups ?? new List(); var knownDeviceList = knownDevices ?? new List(); Text = existing == null ? "Add Sync Channel" : "Edit Sync Channel"; StartPosition = FormStartPosition.CenterParent; FormBorderStyle = FormBorderStyle.FixedDialog; MinimizeBox = false; MaximizeBox = false; ShowInTaskbar = false; ClientSize = new Size(460, 470); KeyPreview = true; var nameLabel = new Label { Text = "&Name:", Location = new Point(12, 14), AutoSize = true }; Controls.Add(nameLabel); nameBox = new TextBox { Location = new Point(100, 10), Width = 340, Text = existing == null ? string.Empty : (existing.Name ?? string.Empty), AccessibleName = "Channel name", AccessibleDescription = "Lowercase letters, digits, spaces, hyphens, and underscores, 1 to 32 characters. The names core, all, pinned, and sync-rules are reserved." }; Controls.Add(nameBox); var groupsLabel = new Label { Text = "&Groups:", Location = new Point(12, 48), AutoSize = true }; Controls.Add(groupsLabel); groupsBox = new CheckedListBox { Location = new Point(12, 66), Size = new Size(420, 90), CheckOnClick = true, AccessibleName = "Groups that route into this channel" }; var checkedGroups = new List(route.Groups ?? new List()); var extraGroups = new List(); foreach (var group in knownGroupList) { groupsBox.Items.Add(group, ContainsFold(checkedGroups, group)); } foreach (var group in checkedGroups) { if (!ContainsFold(knownGroupList, group)) extraGroups.Add(group); } Controls.Add(groupsBox); var otherGroupsLabel = new Label { Text = "Ot&her groups (comma separated):", Location = new Point(12, 160), AutoSize = true }; Controls.Add(otherGroupsLabel); otherGroupsBox = new TextBox { Location = new Point(12, 178), Width = 420, Text = string.Join(", ", extraGroups.ToArray()), AccessibleName = "Other groups", AccessibleDescription = "Additional group names not listed above, separated by commas." }; Controls.Add(otherGroupsBox); var devicesLabel = new Label { Text = "Source de&vices:", Location = new Point(12, 210), AutoSize = true }; Controls.Add(devicesLabel); devicesBox = new CheckedListBox { Location = new Point(12, 228), Size = new Size(420, 90), CheckOnClick = true, AccessibleName = "Source devices this channel captures from" }; var checkedDevices = new List(route.SourceDevices ?? new List()); var extraDevices = new List(); foreach (var device in knownDeviceList) { devicesBox.Items.Add(device, ContainsFold(checkedDevices, device)); } foreach (var device in checkedDevices) { if (!ContainsFold(knownDeviceList, device)) extraDevices.Add(device); } Controls.Add(devicesBox); var otherDevicesLabel = new Label { Text = "Other so&urce devices (comma separated):", Location = new Point(12, 322), AutoSize = true }; Controls.Add(otherDevicesLabel); otherDevicesBox = new TextBox { Location = new Point(12, 340), Width = 420, Text = string.Join(", ", extraDevices.ToArray()), AccessibleName = "Other source devices", AccessibleDescription = "Additional device names not listed above, separated by commas." }; Controls.Add(otherDevicesBox); richTextImagesBox = new CheckBox { Text = "&Rich text images", Location = new Point(12, 372), AutoSize = true, Checked = string.Equals(route.Kind, "RichTextImages", StringComparison.Ordinal), AccessibleName = "Rich text images", AccessibleDescription = "Routes rich-text entries that contain an embedded image into this channel." }; Controls.Add(richTextImagesBox); var ok = new Button { Text = "OK", Location = new Point(270, 410), Size = new Size(85, 28), DialogResult = DialogResult.None, AccessibleName = "OK" }; ok.Click += (s, e) => AcceptChannel(); Controls.Add(ok); var cancel = new Button { Text = "Cancel", Location = new Point(363, 410), Size = new Size(85, 28), DialogResult = DialogResult.Cancel, AccessibleName = "Cancel" }; Controls.Add(cancel); AcceptButton = ok; CancelButton = cancel; ContextHelp.Prepare(this, true); } protected override void OnShown(EventArgs e) { base.OnShown(e); nameBox.Focus(); nameBox.SelectAll(); } protected override void OnKeyDown(KeyEventArgs e) { if (e.KeyCode == Keys.Escape) { DialogResult = DialogResult.Cancel; Close(); e.Handled = true; return; } base.OnKeyDown(e); } private void AcceptChannel() { var candidate = new SyncChannel { Name = nameBox.Text.Trim(), Route = new SyncRoute { Groups = CombineChecked(groupsBox, otherGroupsBox.Text), SourceDevices = CombineChecked(devicesBox, otherDevicesBox.Text), Kind = richTextImagesBox.Checked ? "RichTextImages" : string.Empty } }; var trial = SyncRuleEngine.Copy(contextDocument) ?? new SyncRulesDocument(); if (editingIndex >= 0 && editingIndex < trial.Channels.Count) { trial.Channels[editingIndex] = candidate; } else { trial.Channels.Add(candidate); } var error = SyncRuleEngine.Validate(trial); if (error != null) { MessageBox.Show(this, error, "Clipman Sync Rules", MessageBoxButtons.OK, MessageBoxIcon.Warning); nameBox.Focus(); return; } Result = candidate; DialogResult = DialogResult.OK; Close(); } private static List CombineChecked(CheckedListBox list, string extraText) { var result = new List(); foreach (var item in list.CheckedItems) { var text = Convert.ToString(item); if (!ContainsFold(result, text)) result.Add(text); } foreach (var extra in ParseCommaList(extraText)) { if (!ContainsFold(result, extra)) result.Add(extra); } return result; } private static List ParseCommaList(string text) { var result = new List(); foreach (var part in (text ?? string.Empty).Split(',')) { var trimmed = part.Trim(); if (trimmed.Length > 0 && !ContainsFold(result, trimmed)) result.Add(trimmed); } return result; } private static bool ContainsFold(List list, string value) { foreach (var item in list) { if (string.Equals(item, value, StringComparison.OrdinalIgnoreCase)) return true; } return false; } } /// /// Edits one device's channel subscription list: either the "*" wildcard (all channels) or an /// explicit checked subset of the current channels in the rules document. /// internal sealed class SyncDeviceSubscriptionForm : Form { private readonly CheckBox allChannelsBox; private readonly CheckedListBox channelsBox; public List SelectedChannels { get; private set; } public SyncDeviceSubscriptionForm(string deviceName, List allChannels, List currentChannels) { var channels = allChannels ?? new List(); var current = currentChannels ?? new List(); var isAll = current.Count == 1 && (current[0] ?? string.Empty).Trim() == "*"; Text = "Edit Subscriptions - " + (deviceName ?? string.Empty); StartPosition = FormStartPosition.CenterParent; FormBorderStyle = FormBorderStyle.FixedDialog; MinimizeBox = false; MaximizeBox = false; ShowInTaskbar = false; ClientSize = new Size(360, 320); KeyPreview = true; allChannelsBox = new CheckBox { Text = "&All channels", Location = new Point(12, 12), AutoSize = true, Checked = isAll, AccessibleName = "All channels", AccessibleDescription = "When checked, this device receives every channel, including ones added later. The main history is always received." }; Controls.Add(allChannelsBox); var channelsLabel = new Label { Text = "&Channels received:", Location = new Point(12, 42), AutoSize = true }; Controls.Add(channelsLabel); channelsBox = new CheckedListBox { Location = new Point(12, 60), Size = new Size(330, 190), CheckOnClick = true, Enabled = !isAll, AccessibleName = "Channels received", AccessibleDescription = "Channels this device downloads besides the main history." }; foreach (var channel in channels) { if (channel == null) continue; var key = SyncRuleEngine.ChannelKey(channel.Name); var isChecked = isAll || ContainsFold(current, key); channelsBox.Items.Add(channel.Name, isChecked); } Controls.Add(channelsBox); allChannelsBox.CheckedChanged += (s, e) => channelsBox.Enabled = !allChannelsBox.Checked; var ok = new Button { Text = "OK", Location = new Point(178, 264), Size = new Size(85, 28), DialogResult = DialogResult.None, AccessibleName = "OK" }; ok.Click += (s, e) => AcceptSubscriptions(); Controls.Add(ok); var cancel = new Button { Text = "Cancel", Location = new Point(271, 264), Size = new Size(85, 28), DialogResult = DialogResult.Cancel, AccessibleName = "Cancel" }; Controls.Add(cancel); AcceptButton = ok; CancelButton = cancel; ContextHelp.Prepare(this, true); } protected override void OnShown(EventArgs e) { base.OnShown(e); allChannelsBox.Focus(); } protected override void OnKeyDown(KeyEventArgs e) { if (e.KeyCode == Keys.Escape) { DialogResult = DialogResult.Cancel; Close(); e.Handled = true; return; } base.OnKeyDown(e); } private void AcceptSubscriptions() { if (allChannelsBox.Checked) { SelectedChannels = new List { "*" }; } else { var result = new List(); for (var i = 0; i < channelsBox.Items.Count; i++) { if (!channelsBox.GetItemChecked(i)) continue; var name = Convert.ToString(channelsBox.Items[i]); var key = SyncRuleEngine.ChannelKey(name); if (key.Length > 0 && !result.Contains(key)) result.Add(key); } SelectedChannels = result; } DialogResult = DialogResult.OK; Close(); } private static bool ContainsFold(List list, string value) { foreach (var item in list) { if (string.Equals((item ?? string.Empty).Trim(), value, StringComparison.OrdinalIgnoreCase)) return true; } return false; } } }