Skip to content

Commit

Permalink
Bi link (#247)
Browse files Browse the repository at this point in the history
* bi

* bi poc

* bi poc

* bi poc

* bi poc

* bi poc

* bi poc

* poc

* poc

* poc

* poc

* poc

* poc

* poc

* poc

* poc

* poc

* Bi-directional Link command

* Bi-directional Link Command

* Bi-directional linking command, Clear background fix

* Bi-direction linking command, Clear background fix

* Bi-directional lnking, Fix Clear background
  • Loading branch information
stevencohn authored Aug 7, 2021
1 parent 5cd50a6 commit cb12ffb
Show file tree
Hide file tree
Showing 21 changed files with 1,308 additions and 219 deletions.
6 changes: 6 additions & 0 deletions OneMore/AddInCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ public async Task EnableSpellCheckCmd(IRibbonControl control)
public async Task ExpandContentCmd(IRibbonControl control)
=> await factory.Run<ExpandoCommand>(Expando.Expand);

public async Task FinishBiLinkCmd(IRibbonControl control)
=> await factory.Run<BiLinkCommand>("link");

public async Task GetImagesCmd(IRibbonControl control)
=> await factory.Run<GetImagesCommand>(true);

Expand Down Expand Up @@ -288,6 +291,9 @@ public async Task SplitCmd(IRibbonControl control)
public async Task SplitTableCmd(IRibbonControl control)
=> await factory.Run<SplitTableCommand>();

public async Task StartBiLinkCmd(IRibbonControl control)
=> await factory.Run<BiLinkCommand>("mark");

public async Task StrikeoutCmd(IRibbonControl control)
=> await factory.Run<StrikeoutCommand>();

Expand Down
10 changes: 5 additions & 5 deletions OneMore/Commands/Clean/ClearBackgroundCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,16 @@ private bool ClearTextBackground(IEnumerable<XElement> runs, bool deep = false)
var darkText = !style.Color.Equals(Style.Automatic)
&& ColorTranslator.FromHtml(style.Color).GetBrightness() < 0.5;

// if dark-on-dkar or light-on-light
// if dark-on-dark or light-on-light
if (darkText == darkPage)
{
style.Color = darkText ? White : Black;

var stylizer = new Stylizer(style);
stylizer.ApplyStyle(run);
updated = true;
}

var stylizer = new Stylizer(style);
stylizer.ApplyStyle(run);
updated = true;

// deep prevents runs from being processed multiple times

// NOTE sometimes OneNote will incorrectly set the collapsed attribute,
Expand Down
307 changes: 307 additions & 0 deletions OneMore/Commands/References/BiLinkCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
//************************************************************************************************
// Copyright © 2021 Steven M Cohn. All rights reserved.
//************************************************************************************************

namespace River.OneMoreAddIn
{
using River.OneMoreAddIn.Models;
using System.Linq;
using System.Threading.Tasks;
using System.Xml.Linq;
using Resx = River.OneMoreAddIn.Properties.Resources;


/// <summary>
/// Create a bi-directional link between two selected words or phrases, either across pages
/// or on the same page. This is invoked as two commands, the first to mark the first word
/// or phrase and the second to select and create the link with the second words or phrase.
/// </summary>
internal class BiLinkCommand : Command
{
// TODO: consider moving these to a global state cache that can be pruned
// rather than holding on to them indefinitely as statics....

private static string anchorPageId;
private static string anchorId;
private static SelectionRange anchor;

private string anchorText;
private string error;


public BiLinkCommand()
{
}


public override async Task Execute(params object[] args)
{
using (var one = new OneNote())
{
if ((args[0] is string cmd) && (cmd == "mark"))
{
if (!MarkAnchor(one))
{
UIHelper.ShowInfo(one.Window, Resx.BiLinkCommand_BadAnchor);
IsCancelled = true;
return;
}

if (anchorText.Length > 20) { anchorText = $"{anchorText.Substring(0,20)}..."; }
UIHelper.ShowInfo(one.Window, string.Format(Resx.BiLinkCommand_Marked, anchorText));
}
else
{
if (string.IsNullOrEmpty(anchorPageId))
{
UIHelper.ShowInfo(one.Window, Resx.BiLinkCommand_NoAnchor);
IsCancelled = true;
return;
}

if (!await CreateLinks(one))
{
UIHelper.ShowInfo(one.Window, string.Format(Resx.BiLinkCommand_BadTarget, error));
IsCancelled = true;
return;
}

// reset
anchorPageId = null;
anchorId = null;
anchor = null;
}
}

await Task.Yield();
}


private bool MarkAnchor(OneNote one)
{
var page = one.GetPage();
var range = new SelectionRange(page.Root);

// get selected runs but preserve cursor if there is one so we can edit from it later
var run = range.GetSelection();
if (run == null)
{
logger.WriteLine("no selected content");
return false;
}

// anchor is the surrounding OE
anchor = new SelectionRange(run.Parent);

anchorId = anchor.ObjectId;
if (string.IsNullOrEmpty(anchorId))
{
logger.WriteLine("missing objectID");
anchor = null;
return false;
}

anchorPageId = one.CurrentPageId;
anchorText = page.GetSelectedText();
return true;
}


private async Task<bool> CreateLinks(OneNote one)
{
// - - - - anchor...

var anchorPage = one.GetPage(anchorPageId);
if (anchorPage == null)
{
logger.WriteLine($"lost anchor page {anchorPageId}");
error = Resx.BiLinkCommand_LostAnchor;
return false;
}

var candidate = anchorPage.Root.Descendants()
.FirstOrDefault(e => e.Attributes("objectID").Any(a => a.Value == anchorId));

if (candidate == null)
{
logger.WriteLine($"lost anchor paragraph {anchorId}");
error = Resx.BiLinkCommand_LostAnchor;
return false;
}

// ensure anchor selection hasn't changed and is still selected!
if (AnchorModified(candidate, anchor.Root))
{
logger.WriteLine($"anchor paragraph may have changed");
error = Resx.BiLinkCommand_LostAnchor;
return false;
}

// - - - - target...

Page targetPage = anchorPage;
var targetPageId = anchorPageId;
if (one.CurrentPageId != anchorPageId)
{
targetPage = one.GetPage();
targetPageId = targetPage.PageId;
}

var range = new SelectionRange(targetPage.Root);
var targetRun = range.GetSelection();
if (targetRun == null)
{
logger.WriteLine("no selected target content");
error = Resx.BiLinkCommand_NoTarget;
return false;
}

var target = new SelectionRange(targetRun.Parent);
var targetId = target.ObjectId;
if (anchorId == targetId)
{
logger.WriteLine("cannot link a phrase to itself");
error = Resx.BiLinkCommand_Circular;
return false;
}

// - - - - action...

// anchorPageId -> anchorPage -> anchorId -> anchor
// targetPageId -> targetPage -> targetId -> target

var anchorLink = one.GetHyperlink(anchorPageId, anchorId);
var targetLink = one.GetHyperlink(targetPageId, targetId);

ApplyHyperlink(anchorPage, anchor, targetLink);
ApplyHyperlink(targetPage, target, anchorLink);

candidate.ReplaceAttributes(anchor.Root.Attributes());
candidate.ReplaceNodes(anchor.Root.Nodes());

if (targetPageId == anchorPageId)
{
// avoid invalid selection by leaving only partials without an all
candidate.DescendantsAndSelf().Attributes("selected").Remove();
}

//logger.WriteLine();
//logger.WriteLine("LINKING");
//logger.WriteLine($" anchorPageId = {anchorPageId}");
//logger.WriteLine($" anchorId = {anchorId}");
//logger.WriteLine($" anchorLink = {anchorLink}");
//logger.WriteLine($" candidate = '{candidate}'");
//logger.WriteLine($" targetPageId = {targetPageId}");
//logger.WriteLine($" targetId = {targetId}");
//logger.WriteLine($" targetLink = {targetLink}");
//logger.WriteLine($" target = '{target}'");
//logger.WriteLine();
//logger.WriteLine("---------------------------------------------");
//logger.WriteLine(targetPage.Root);

await one.Update(targetPage);

if (targetPageId != anchorPageId)
{
// avoid invalid selection by leaving only partials without an all
anchorPage.Root.DescendantsAndSelf().Attributes("selected").Remove();
await one.Update(anchorPage);
}

return true;
}


private bool AnchorModified(XElement candidate, XElement anchor)
{
// special deep comparison, excluding the selected attributes to handle
// case where anchor is on the same page as the target element

var oldcopy = new SelectionRange(anchor.Clone());
oldcopy.Deselect();

var newcopy = new SelectionRange(candidate.Clone());
newcopy.Deselect();

var oldxml = oldcopy.ToString();
var newxml = newcopy.ToString();

//if (oldxml != newxml)
//{
// logger.WriteLine("differences found in candidate/anchor");
// logger.WriteLine("oldxml/candidate");
// logger.WriteLine(oldxml);
// logger.WriteLine("newxml/anchor");
// logger.WriteLine(newxml);
//}

return oldxml != newxml;
}


private void ApplyHyperlink(Page page, SelectionRange range, string link)
{
var count = 0;

var selection = range.GetSelection();
if (range.SelectionScope == SelectionScope.Empty)
{
page.EditNode(selection, (s) =>
{
count++;
return new XElement("a", new XAttribute("href", link), s);
});
}
else
{
page.EditSelected(range.Root, (s) =>
{
count++;
return new XElement("a", new XAttribute("href", link), s);
});
}

// combine doubled-up <a/><a/>...
// WARN: this could loose styling

if (count > 0 && range.SelectionScope == SelectionScope.Empty)
{
var cdata = selection.GetCData();

if (selection.PreviousNode is XElement prev)
{
var cprev = prev.GetCData();
var wrapper = cprev.GetWrapper();
if (wrapper.LastNode is XElement node)
{
cdata.Value = $"{node.ToString(SaveOptions.DisableFormatting)}{cdata.Value}";
node.Remove();
cprev.Value = wrapper.GetInnerXml();
}

if (cprev.Value.Length == 0)
{
prev.Remove();
}
}

if (selection.NextNode is XElement next)
{
var cnext = next.GetCData();
var wrapper = cnext.GetWrapper();
if (wrapper.FirstNode is XElement node)
{
cdata.Value = $"{cdata.Value}{node.ToString(SaveOptions.DisableFormatting)}";
node.Remove();
cnext.Value = wrapper.GetInnerXml();
}

if (cnext.Value.Length == 0)
{
next.Remove();
}
}
}
}
}
}
20 changes: 11 additions & 9 deletions OneMore/Helpers/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,15 @@ public static bool StartsWithICIC(this string s, string value)


/// <summary>
/// Extract the first word delimeted by a non-word boundary from the given
/// string and returns the word and updated string.
/// Splits a string into its first word and the remaining characters as delimited by
/// the first non-word boundary.
/// </summary>
/// <param name="s">A string with one or more words</param>
/// <returns>
/// A two-part ValueTuple with the word and the updated string.
/// A two-part ValueTuple with the first word and the remaining string. If the given
/// string does not contain a word boundary then this returns (null,s)
/// </returns>
public static (string, string) ExtractFirstWord(this string s)
public static (string, string) SplitAtFirstWord(this string s)
{
if (!string.IsNullOrEmpty(s))
{
Expand All @@ -96,14 +97,15 @@ public static (string, string) ExtractFirstWord(this string s)


/// <summary>
/// Extract the last word delimeted by a non-word boundary from the given
/// string and returns the word and updated string.
/// Splits a string into its last word and the remaining characters as delimited by
/// the last non-word boundary.
/// </summary>
/// <param name="s">A string with one or more words</param>
/// <returns>
/// A two-part ValueTuple with the word and the updated string.
/// A two-part ValueTuple with the last word and the remaining string. If the given
/// string does not contain a word boundary then this returns (null,s)
/// </returns>
public static (string, string) ExtractLastWord(this string s)
public static (string, string) SplitAtLastWord(this string s)
{
if (!string.IsNullOrEmpty(s))
{
Expand Down Expand Up @@ -143,7 +145,7 @@ public static XElement ToXmlWrapper(this string s)
// quote unquote language attribute, e.g., lang=yo to lang="yo" (or two part en-US)
value = Regex.Replace(value, @"(\s)lang=([\w\-]+)([\s/>])", "$1lang=\"$2\"$3");

return XElement.Parse("<wrapper>" + value + "</wrapper>");
return XElement.Parse($"<wrapper>{value}</wrapper>");
}


Expand Down
Loading

0 comments on commit cb12ffb

Please sign in to comment.