Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions docs/Usage-Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ The application has two tabs: **Pages** (one website channel at a time) and
| Status | Meaning |
| --- | --- |
| **Missing on target** | Published on this instance but absent on the target. |
| **Out of date on target** | Published more recently on this instance than on the target, published on one and unpublished on the other, or moved or reordered (hover the status to see which and what to sync). |
| **Out of date on target** | Published more recently on this instance than on the target, published on one and unpublished on the other, or moved (hover the status to see which and what to sync). |
| **Order differs on target** | The pages on this level are in a different order on the target. See below. |
| **Extra on target** | On the target but not on this instance. |
| **In sync** | The target has the same published version. |

Expand All @@ -159,6 +160,21 @@ Other states:
- **A "Not available" row**: the selected channel, workspace, or language was
deleted after the filter was applied. Clear that filter or choose another.

### Why does a whole level show "Order differs on target"?

Content Sync sends each synced page's position with it, but pages you don't
include keep their old positions on the target. So syncing only some pages of a
level can leave the level in a different order there, for example after
reordering pages here, or after syncing a single new page. The status page then
marks every page on the level, because Content Sync only transfers an order
change when the whole level is synced.

- Hover the tag: it names the page or pages that are out of place.
- To fix it, open the page tree, and on the level's parent page use **Sync with
all subpages**.
- If your site never shows pages in page tree order (for example, a listing
sorted by date), the difference has no visible effect and you can ignore it.

## What is compared

- **Published and unpublished content, not drafts.** An item appears once
Expand All @@ -175,10 +191,10 @@ Other states:
[Publication-state scope](specs/content-inventory-foundation.md#publication-state-scope).
- **Secured pages are included,** like any other page.
- **Moved and reordered pages.** Moving or reordering a page doesn't republish it, so the page compares positions too: a page at a different path
on the target, or a level whose pages are in a different order, shows as
**Out of date on target**. To sync it, Content Sync needs all pages on the
affected level (for a move, the old and the new one); the status tooltip
says so.
on the target shows as **Out of date on target**, and a level whose pages
are in a different order shows as **Order differs on target**. To sync
either, Content Sync needs all pages on the affected level (for a move, the
old and the new one); the status tooltip says so.
- **Deleted items.** Content Sync can't delete anything on the target. An item
deleted here stays on the target and shows as **Extra on target** until
someone deletes it there.
Expand Down
36 changes: 36 additions & 0 deletions docs/specs/content-inventory-foundation.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,19 @@ date (position lives on the page record, `WebPageItemTreePath` and
[Content sync](https://docs.kentico.com/documentation/business-users/content-sync#sync-moved-or-reordered-pages)
documentation says reordered or moved pages need **all** pages on the level
synced. Skipped when either side has no `Order` (a schema 1 target).
Each marked page also carries a `ContentSyncReorder`: the level's parent path
(and the parent page, when it's in the inventory, for its display name) and
the **pages out of place**, the fewest pages whose removal leaves the rest of
the level in the same order on both sides (the pages outside a longest
common subsequence of the two orders, found in O(n log n); ties resolve the
same way every time). The usual cause is a partial sync: Content Sync sends
each synced page's order value, and pages left out keep their old values on
the target, so one page that wasn't synced ends up in another position.
Pages that **share an order value on the target** count as out of order:
Kentico then shows them in whatever order the database returns, so their
order there is undefined. Breaking such ties by GUID (as the first version
did) can report a level `InSync` while the target's site shows it in another
order.

Verified live on 31.7.2: dragging `(Clone) On Roasts` above `On Roasts` in the
source's page tree swapped their `WebPageItemOrder` (7/6 → 6/7) and left both
Expand All @@ -387,6 +400,29 @@ the source, stayed `MissingOnTarget`. Before the reorder the same level was
cause a false reorder. The move rule isn't verified live (no page was moved to
another parent); it's covered by unit tests.

Also seen on the rig: syncing only some articles moved them to their source
positions on the target, but `Coffee Beverages Explained`, not in that sync,
kept its old order value there and so came first on the target and third on
the source. The status page marked the level, and the out-of-place page is
exactly that one; Sync with all subpages on Articles brought the level back to
`InSync`.

End to end on 31.7.2, with the Playwright CLI:

1. Dragged `Which brewing fits you?` from last to first in the source's page
tree. Every article showed **Order differs on target**, and the tooltip
named only that page.
2. Used **Sync this page** on it. Kentico also synced the articles it links to,
which brought their source order values, so two pairs of articles ended up
sharing a value on the target (3/3 and 8/8), and the target's page tree
showed one pair (`On Roasts`, `(Clone) On Roasts`) in the opposite order to
the source. The first version of this rule reported the level `InSync` here,
because its GUID tie-break happened to match the source; with the tie rule
above, the level shows Order differs and the tooltip names one page of each
tied pair.
3. Used **Sync with all subpages** on Articles. The level returned to `InSync`,
and the target's page tree matched the source's order.

Each `ContentSyncStatusItem` carries a `Reason` for `OutOfDateOnTarget`:
`PublishedMoreRecently`, `PublishStateDiffers`, `Moved`, or `Reordered`
(`None` otherwise), so consumers can explain the status.
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/sync-status-admin-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ the comparer's `Reason` and each side's publication state:
| --- | --- |
| Unpublished on one side | "Unpublished here, still published on the target." / "Published here, unpublished on the target." |
| Moved | "Moved here. To move it on the target, sync all pages on its old and new level." |
| Reordered | "Page order on this level changed here. To reorder the target, sync all pages on this level." |
| Reordered | The tag reads **Order differs on target** (the status is still Out of date, so sorting and filters are unchanged). Tooltip: "Page order on this level differs on the target: Coffee Beverages Explained is in a different position there. This usually happens when only some pages of a level are synced. To fix it, use Sync with all subpages on Articles." It names up to 3 out-of-place pages, then "and N more", by display name; on the channel's top level it says to sync all pages on the level. Positions aren't given as numbers, because the page tree also shows drafts, which aren't compared. |
| Published more recently | "Published here after the target's copy." |
| Only on the target | "Only on the target. Content Sync can't delete content: if it was deleted here, delete it on the target." |
| Unpublished on both, or only here | "Unpublished on both instances." / "Unpublished here, and not on the target yet." |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,14 +228,75 @@ public static string IssueHtml(RequiredObjectIssue issue) =>
_ when !localUnpublished && remoteUnpublished => "Published here, unpublished on the target.",
_ when item.Reason == ContentSyncStatusReason.Moved =>
"Moved here. To move it on the target, sync all pages on its old and new level.",
_ when item.Reason == ContentSyncStatusReason.Reordered =>
"Page order on this level changed here. To reorder the target, sync all pages on this level.",
_ when item.Reason == ContentSyncStatusReason.Reordered => ReorderTooltip(item.Reorder),
_ when item.Reason == ContentSyncStatusReason.PublishedMoreRecently => "Published here after the target's copy.",
_ when localUnpublished => "Unpublished on both instances.",
_ => null,
};
}

// How many out-of-place pages a tooltip names before "and N more".
private const int MaxNamedPages = 3;

// Names the pages out of place, says the usual cause, and how to fix it. Positions aren't given
// as numbers: the page tree also shows drafts, which aren't compared, so "3rd" could disagree
// with what the editor sees.
private static string ReorderTooltip(ContentSyncReorder? reorder)
{
const string cause = " This usually happens when only some pages of a level are synced.";

if (reorder is null || reorder.MisplacedPages.Count == 0)
{
return "Page order on this level differs on the target." + cause + " " + ReorderFix(reorder);
}

var names = reorder.MisplacedPages.Take(MaxNamedPages).Select(PageName).ToList();
string list = JoinNames(names, reorder.MisplacedPages.Count - names.Count);
string verb = reorder.MisplacedPages.Count == 1 ? "is" : "are";

return $"Page order on this level differs on the target: {list} {verb} in a different position there."
+ cause + " " + ReorderFix(reorder);
}

// "A", "A and B", "A, B and C", or "A, B, C and 2 more".
private static string JoinNames(IReadOnlyList<string> names, int more)
{
if (more > 0)
{
return string.Join(", ", names) + $" and {more} more";
}

return names.Count == 1 ? names[0] : string.Join(", ", names.Take(names.Count - 1)) + " and " + names[^1];
}

// Kentico's "Sync with all subpages" on the parent syncs the whole level. The top level has no
// parent page to sync from.
private static string ReorderFix(ContentSyncReorder? reorder)
{
if (reorder is null || reorder.ParentPath.Length == 0)
{
return "To fix it, sync all pages on this level.";
}

string parent = reorder.Parent is { } parentPage
? PageName(parentPage)
: reorder.ParentPath[(reorder.ParentPath.LastIndexOf('/') + 1)..];

return $"To fix it, use Sync with all subpages on {parent}.";
}

private static string PageName(ContentInventoryItem page) =>
NonEmpty(page.DisplayName)
?? (page.TreePath is { } path ? path[(path.LastIndexOf('/') + 1)..] : null)
?? page.Name;

/// <summary>
/// The tag label: the status, except that a page out of date only because its level's order
/// differs says so, since that's fixed differently (sync the whole level).
/// </summary>
public static string StatusLabel(ContentSyncStatusItem item) =>
item.Reason == ContentSyncStatusReason.Reordered ? "Order differs on target" : StatusLabel(item.Status);

public static string StatusLabel(ContentSyncStatus status) => status switch
{
ContentSyncStatus.InSync => "In sync",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ internal abstract class ContentSyncStatusTabBase(
private const string StatusTooltip =
"<strong>Missing on target</strong>: published here, not on the target yet.<br>"
+ "<strong>Out of date on target</strong>: the target has an older published version.<br>"
+ "<strong>Order differs on target</strong>: the pages on this level are in a different order on the target.<br>"
+ "<strong>Extra on target</strong>: on the target, but not published here.<br>"
+ "<strong>In sync</strong>: the target has the same published version.";

Expand Down Expand Up @@ -330,7 +331,7 @@ private static Row ToRow(ContentSyncStatusItem item, string? link) =>
[
new StringCell { Value = ContentSyncStatusListingSupport.DisplayName(item) },
new StringCell { Value = ContentSyncStatusListingSupport.ContentTypeDisplayName(item) },
TagCell(ContentSyncStatusListingSupport.StatusLabel(item.Status), ContentSyncStatusListingSupport.StatusColor(item.Status), ContentSyncStatusListingSupport.StatusTooltip(item)),
TagCell(ContentSyncStatusListingSupport.StatusLabel(item), ContentSyncStatusListingSupport.StatusColor(item.Status), ContentSyncStatusListingSupport.StatusTooltip(item)),
// Kentico's own local date-time cell: the browser shows it in the editor's time zone.
LocalDateTimeCell(ContentSyncStatusListingSupport.LastPublishedWhen(item)),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,13 @@ private static bool IsPublishedMoreRecently(DateTime? localPublished, DateTime?
// compared directly: a page that exists on only one side shifts every later sibling's value. So
// for each parent, the pages present on both sides are compared by their relative order, and if
// it differs, every in-sync page on that level is marked — Content Sync needs all pages on the
// level synced to transfer the order. Order is null from targets on schema version 1.
// level synced to transfer the order. Each marked page also says which pages are out of place,
// so editors see what changed. Order is null from targets on schema version 1.
//
// Pages sharing an order value on the target count as out of order: a partial sync leaves such
// ties (each synced page brings its own value, the others keep theirs), and Kentico then shows
// them in whatever order the database returns them (seen on the rig: two tied pages showed in
// the opposite order to the source). A GUID tie-break would hide that.
private static List<ContentSyncStatusItem> MarkReorderedSiblings(List<ContentSyncStatusItem> results)
{
var levels = results
Expand All @@ -96,16 +102,32 @@ private static List<ContentSyncStatusItem> MarkReorderedSiblings(List<ContentSyn
&& item.Reason != ContentSyncStatusReason.Moved)
.GroupBy(item => ParentPath(item.Local!.TreePath!), StringComparer.OrdinalIgnoreCase);

var reordered = new HashSet<Guid>();
var reordered = new Dictionary<Guid, ContentSyncReorder>();

// The parent page's name for messages, when it's in the inventory (a folder or an unpublished
// parent isn't).
var namesByPath = results
.Where(item => item.Local?.TreePath is not null)
.GroupBy(item => item.Local!.TreePath!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First().Local!, StringComparer.OrdinalIgnoreCase);

foreach (var level in levels)
{
var localOrder = level.OrderBy(item => item.Local!.Order).ThenBy(item => item.Guid).Select(item => item.Guid);
var remoteOrder = level.OrderBy(item => item.Remote!.Order).ThenBy(item => item.Guid).Select(item => item.Guid);
var localOrder = level.OrderBy(item => item.Local!.Order).ThenBy(item => item.Guid).ToList();
var misplaced = FindMisplaced(localOrder, item => item.Remote!.Order!.Value);

if (misplaced.Count == 0)
{
continue;
}

if (!localOrder.SequenceEqual(remoteOrder))
var reorder = new ContentSyncReorder(level.Key, [.. misplaced.Select(item => item.Local!)])
{
Parent = namesByPath.GetValueOrDefault(level.Key),
};
foreach (var item in level.Where(item => item.Status == ContentSyncStatus.InSync))
{
reordered.UnionWith(level.Where(item => item.Status == ContentSyncStatus.InSync).Select(item => item.Guid));
reordered[item.Guid] = reorder;
}
}

Expand All @@ -114,11 +136,62 @@ private static List<ContentSyncStatusItem> MarkReorderedSiblings(List<ContentSyn
return results;
}

return [.. results.Select(item => reordered.Contains(item.Guid)
? item with { Status = ContentSyncStatus.OutOfDateOnTarget, Reason = ContentSyncStatusReason.Reordered }
return [.. results.Select(item => reordered.TryGetValue(item.Guid, out var reorder)
? item with { Status = ContentSyncStatus.OutOfDateOnTarget, Reason = ContentSyncStatusReason.Reordered, Reorder = reorder }
: item)];
}

// The fewest pages to take out so the rest are in the same order on both sides: the pages
// outside a longest strictly increasing run of target order values, taken in local order. Pages
// tied on the target can't both be in place, so all but one of a tied group are returned.
// Returned in local order; with several equally short answers, the same one every time (the
// local order has a GUID tie-break). Empty when the level is in the same order.
internal static IReadOnlyList<ContentSyncStatusItem> FindMisplaced(
IReadOnlyList<ContentSyncStatusItem> localOrder, Func<ContentSyncStatusItem, int> remoteOrderValue)
{
int[] positions = [.. localOrder.Select(remoteOrderValue)];

// Patience sorting: tails[k] is the index (into positions) ending the best run of length k+1.
var tails = new List<int>();
int[] previous = new int[positions.Length];

for (int i = 0; i < positions.Length; i++)
{
int low = 0;
int high = tails.Count;
while (low < high)
{
int middle = (low + high) / 2;
if (positions[tails[middle]] < positions[i])
{
low = middle + 1;
}
else
{
high = middle;
}
}

previous[i] = low > 0 ? tails[low - 1] : -1;
if (low == tails.Count)
{
tails.Add(i);
}
else
{
tails[low] = i;
}
}

var kept = new HashSet<int>();
for (int i = tails.Count > 0 ? tails[^1] : -1; i >= 0; i = previous[i])
{
kept.Add(i);
}

return [.. localOrder.Where((_, index) => !kept.Contains(index))];
}

private static string ParentPath(string treePath)
{
int lastSlash = treePath.LastIndexOf('/');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,24 @@ public sealed record ContentSyncStatusItem(
/// other status, and when the target couldn't be checked.
/// </summary>
public IReadOnlyList<RequiredObjectIssue> RequiredObjectIssues { get; init; } = [];

/// <summary>
/// For an item that's <see cref="ContentSyncStatusReason.Reordered"/>, its level and the pages on
/// it that are out of place on the target; <see langword="null"/> otherwise.
/// </summary>
public ContentSyncReorder? Reorder { get; init; }
}

/// <summary>A level of the page tree whose pages are in a different order on the target.</summary>
/// <param name="ParentPath">The tree path of the level's parent page; empty for the channel's top level.</param>
/// <param name="MisplacedPages">
/// The fewest pages (this instance's copies, in this instance's order) whose positions differ: with
/// them left out, the rest of the level is in the same order on both sides.
/// </param>
public sealed record ContentSyncReorder(string ParentPath, IReadOnlyList<ContentInventoryItem> MisplacedPages)
{
/// <summary>The parent page, when it's in this instance's inventory, for its display name.</summary>
public ContentInventoryItem? Parent { get; init; }
}

/// <summary>Why an item is <see cref="ContentSyncStatus.OutOfDateOnTarget"/>.</summary>
Expand Down
Loading
Loading