diff --git a/CHANGELOG.md b/CHANGELOG.md index bd44b9a..d8eeba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to PaperRoute Tracker will be documented here. +## [Unreleased] — v0.7 Deadline Center + +v0.7.0-rc.1 is available on the Preview channel. Development is tracked in [PR #78](https://github.com/JUhalt/PaperRoute-Tracker/pull/78) and the [v0.7 milestone](https://github.com/JUhalt/PaperRoute-Tracker/milestone/9). + +### Added + +- The **Deadlines** page replaces Reminders ([#28](https://github.com/JUhalt/PaperRoute-Tracker/issues/28)): one list of what needs action, grouped as Overdue, Today, Next 7 days, Later, No date, and Done (folded away). Counts across the top; chips for Revisions, Follow-ups, Reminders, and Preparation; and a filter box. Revisions show progress from the reviewer comments on their decision. Work without a date stays visible: a revision with no deadline, and an unsubmitted packet whose checklist has required items open. +- Each row acts on the record that owns it: **Open** lands on the submission (at its reviewer responses for a revision), **Postpone...** offers tomorrow, a week, two weeks, or any date, **Done** completes a reminder, **Clear Follow-up...** removes only the follow-up date, and **Open Readiness** opens Readiness & Packets. Enter runs a row's first action. Nothing is copied, so Deadlines and the manuscript page always agree. +- The rail's Deadlines badge counts what is overdue or due today, and **View in Deadlines →** sits beside Needs Attention on the Board. **Ctrl+4** opens Deadlines. +- **Check for Publications...** ([#61](https://github.com/JUhalt/PaperRoute-Tracker/issues/61)) asks Crossref, only when you choose, whether manuscripts you are tracking have been published: by DOI, by a preprint's link to its published version, by title, and optionally in an ORCID record. Each possible match offers **Review Match**, **Mark Published...**, and **Ignore**; unreviewed matches wait on the Deadlines page. From Import & Export, or a manuscript page's **⋯** menu. +- **Mark Published...** lists exactly what it will change, then records a normal lifecycle event: the Published shelf and stage from the publication date, a history entry with the journal and DOI, and empty publication fields filled. Submissions and decisions are left as recorded. +- **Fill Blanks from Crossref...** fills empty publication fields for manuscripts with a DOI after a preview of every change. It never replaces a value. + +### Storage + +- **Schema 7** remembers publication matches, so an ignored match stays ignored. The upgrade validates the library and changes only the schema marker; manuscript data, managed files, and backups are left byte-for-byte. v0.6 cannot open a library after v0.7 has used it; keep a backup made before upgrading. + +### Fixed + +- Revision deadlines now name their journal on the Deadlines page and in calendar exports. +- The **First deadline** date in Add Manuscript shows in full at 150% display scaling ([#76](https://github.com/JUhalt/PaperRoute-Tracker/issues/76)). + ## [0.6.0] - 2026-09-27 PaperRoute v0.6.0 — **Workspace** — was released on September 27, 2026 from [PR #69](https://github.com/JUhalt/PaperRoute-Tracker/pull/69), after the v0.6.0-rc.1 Preview. Certification is recorded in [#74](https://github.com/JUhalt/PaperRoute-Tracker/issues/74). v0.6 uses the same Schema 6 library as v0.5 and migrates no data. diff --git a/ManualCertification/V04Demo/BoardDemo.cs b/ManualCertification/V04Demo/BoardDemo.cs index 31eba2a..ca11843 100644 --- a/ManualCertification/V04Demo/BoardDemo.cs +++ b/ManualCertification/V04Demo/BoardDemo.cs @@ -131,6 +131,35 @@ internal static void CreateSamples(string sessionRoot, bool empty = false) "We explain why the preregistered analysis is retained.", "Study 2") }); + // Deadlines: follow-ups, reminders (one done), and an unsubmitted + // packet whose checklist is still open. + var submitted = manuscripts[1]; + submitted.Submissions.Single().FollowUpDate = today.AddDays(24); + var underReview = manuscripts[2]; + underReview.Submissions.Last().FollowUpDate = today.AddDays(-4); + underReview.PublicationMatches.Add(new PublicationMatch + { + Doi = "10.5555/demo.attention", Title = "Attention capture by salient distractors under working-memory load", + Journal = "Fictional Journal of Perception & Performance", PublishedDate = today.AddDays(-2), Source = PublicationMatchSource.Title + }); + var draft = manuscripts[4]; + draft.Reminders.Add(new ManuscriptReminder { Title = "Send the revised draft to coauthors", DueDate = today }); + draft.Reminders.Add(new ManuscriptReminder { Title = "Ask the librarian about the search strategy", DueDate = today.AddDays(5) }); + draft.Reminders.Add(new ManuscriptReminder { Title = "Book the ethics amendment", DueDate = today.AddDays(-6), IsCompleted = true, CompletedDate = today.AddDays(-2) }); + var readiness = new ManuscriptReadiness { JournalName = "Fictional Nurse Education Review" }; + readiness.Items.AddRange(new[] + { + new ReadinessItemState { Title = "Cover letter", IsRequired = true, Status = ReadinessItemStatus.Complete }, + new ReadinessItemState { Title = "Ethics statement", IsRequired = true, Status = ReadinessItemStatus.Complete }, + new ReadinessItemState { Title = "Data availability statement", IsRequired = true, Status = ReadinessItemStatus.Unresolved }, + new ReadinessItemState { Title = "Structured abstract", IsRequired = true, Status = ReadinessItemStatus.Unresolved } + }); + draft.ReadinessProfiles.Add(readiness); + var draftVersion = new ManuscriptVersion { Label = "Submission draft", CreatedDate = today.AddDays(-3), Notes = "Fictional version with no file." }; + draft.Versions.Add(draftVersion); + draft.SubmissionPackets.Add(new SubmissionPacket { ReadinessProfileId = readiness.Id, JournalName = readiness.JournalName, Label = "Initial submission", ManuscriptVersionId = draftVersion.Id }); + manuscripts[0].Reminders.Add(new ManuscriptReminder { Title = "Outline the introduction", DueDate = today.AddDays(12) }); + var library = new AuthorLibraryData(); library.Journals.Add(openPsychology); new AuthorLibraryRepository().Save(empty ? new AuthorLibraryData() : library); diff --git a/ManualCertification/V04Demo/Program.cs b/ManualCertification/V04Demo/Program.cs index 8a75285..3d39fd3 100644 --- a/ManualCertification/V04Demo/Program.cs +++ b/ManualCertification/V04Demo/Program.cs @@ -8,7 +8,7 @@ namespace PaperRoute.V04Demo; internal static class Program { private const string Usage = "PaperRoute workflow manual demo\n\n" + - "Surfaces: vault (default), readiness, packet, packet-new, file, file-new, notes, submission, responses, workflow, board\n" + + "Surfaces: vault (default), readiness, packet, packet-new, file, file-new, notes, submission, responses, workflow, board, publications, fill\n" + "Options: --minimum, --primary, --empty (vault/readiness/board), --integrity (populated vault only), --dark or --system, --help\n\n" + "Default surfaces discard manuscript changes when the window closes.\n" + "workflow and board save only in a new disposable temporary session.\n" + @@ -27,7 +27,7 @@ private static void Main(string[] args) return; } - var surfaces = new[] { "vault", "readiness", "packet", "packet-new", "file", "file-new", "notes", "submission", "responses", "workflow", "board" }; + var surfaces = new[] { "vault", "readiness", "packet", "packet-new", "file", "file-new", "notes", "submission", "responses", "workflow", "board", "publications", "fill" }; var positional = args.Where(argument => !argument.StartsWith("--")).ToArray(); var surface = positional.FirstOrDefault()?.ToLowerInvariant() ?? "vault"; var minimum = args.Contains("--minimum", StringComparer.OrdinalIgnoreCase); @@ -78,6 +78,15 @@ private static void Main(string[] args) return; } + if (surface == "publications" || surface == "fill") + { + using var check = PublicationDemo.Create(surface); + check.Text += " [DEMO - synthetic answers; nothing saved]"; + ConfigureDisplayEvidence(check, primary); + check.ShowDialog(); + return; + } + if (surface == "workflow") { // Configure before constructing ANY sample, repository, or form. diff --git a/ManualCertification/V04Demo/PublicationDemo.cs b/ManualCertification/V04Demo/PublicationDemo.cs new file mode 100644 index 0000000..2ec8053 --- /dev/null +++ b/ManualCertification/V04Demo/PublicationDemo.cs @@ -0,0 +1,65 @@ +using ManuscriptPipeline.Forms; +using ManuscriptPipeline.Models; +using ManuscriptPipeline.Services; + +namespace PaperRoute.V04Demo; + +// The publication check and Fill Blanks (#61) on fictional records, with +// synthetic answers instead of Crossref and ORCID. Nothing is saved. +internal static class PublicationDemo +{ + internal static Form Create(string surface) + { + var today = DateTime.Today; + var library = new List + { + Sample("DEMO Anchoring effects in clinical risk estimates: a preregistered replication", PaperStage.UnderReview, "Fictional Open Psychology", today.AddDays(-140)), + Sample("DEMO Measurement invariance of a short grit scale across four countries", PaperStage.Accepted, "Fictional Assessment Quarterly", today.AddDays(-300)), + Sample("DEMO Attention capture by salient distractors under working-memory load", PaperStage.Submitted, "Fictional Journal of Perception & Performance", today.AddDays(-20)), + Sample("DEMO Pilot notes on reading fluency", PaperStage.Idea, "", today.AddDays(-5)) + }; + library[1].Metadata.Doi = "10.5555/demo.grit"; + var source = new SyntheticSource(library, today); + + if (surface == "fill") + { + library[1].Metadata.PublicationJournal = "Fictional Assessment Quarterly"; + return new FillBlanksForm(library, source, () => true) { Pause = TimeSpan.Zero }; + } + return new PublicationCheckForm(library, null, "0000-0002-1825-0097", source, () => true, today) { Pause = TimeSpan.FromMilliseconds(400) }; + } + + private static Manuscript Sample(string title, PaperStage stage, string journal, DateTime submitted) + { + var manuscript = new Manuscript { Title = title, CurrentStage = stage, TargetJournal = journal, Location = ManuscriptLocation.Pipeline }; + if (stage >= PaperStage.Submitted) + manuscript.Submissions.Add(new JournalSubmission { JournalName = journal, SubmittedDate = submitted }); + return manuscript; + } + + private sealed class SyntheticSource(List library, DateTime today) : IPublicationSource + { + public Task LookupDoiAsync(string doi, CancellationToken cancellationToken) => + Task.FromResult(doi == "10.5555/demo.grit" + ? new CrossrefMetadataSuggestion + { + Doi = doi, Title = library[1].Title.Replace("DEMO ", ""), Journal = "Fictional Assessment Quarterly", + Publisher = "Fictional Society Press", PublishedDate = today.AddDays(-9), Volume = "33", Issue = "4", Pages = "512-529", + Url = "https://example.org/fictional/grit", WorkType = "journal-article", + AbstractText = "A fictional abstract for the Fill Blanks preview." + } + : null); + + public Task> SearchTitleAsync(string title, CancellationToken cancellationToken) => + Task.FromResult(title == library[0].Title + ? new List + { + new() { Doi = "10.5555/demo.anchoring", Title = "Anchoring effects in clinical risk estimates: A preregistered replication", + Journal = "Fictional Open Psychology", PublishedDate = today.AddDays(-4), WorkType = "journal-article", Volume = "12", Pages = "e1043" } + } + : new List()); + + public Task> OrcidWorksAsync(string orcid, CancellationToken cancellationToken) => + Task.FromResult(new List()); + } +} diff --git a/ManuscriptPipeline/Controls/DeadlineRow.vb b/ManuscriptPipeline/Controls/DeadlineRow.vb new file mode 100644 index 0000000..0e8aa75 --- /dev/null +++ b/ManuscriptPipeline/Controls/DeadlineRow.vb @@ -0,0 +1,391 @@ +Imports System.Drawing +Imports System.Drawing.Drawing2D +Imports System.Globalization +Imports System.Windows.Forms +Imports ManuscriptPipeline.Models +Imports ManuscriptPipeline.Services + +Namespace Controls + + ' One row on the Deadlines page: when, what kind, what and for which + ' manuscript, any progress, and its actions. The row itself takes focus, + ' so arrow keys move between rows, Enter runs the first action, and the + ' context-menu key opens every action. + Friend Class DeadlineRow + Inherits Control + + Private ReadOnly _today As DateTime + Private ReadOnly _actions As FlowLayoutPanel + Private ReadOnly _menu As New ContextMenuStrip() + Private _primary As Action = Nothing + Private _boldFont As Font + Private _smallFont As Font + Private _dayFont As Font + + Public Sub New(item As DeadlineItem, today As DateTime) + + Me.Item = item + _today = today.Date + + SetStyle(ControlStyles.UserPaint Or ControlStyles.AllPaintingInWmPaint Or ControlStyles.OptimizedDoubleBuffer Or + ControlStyles.ResizeRedraw Or ControlStyles.Selectable, True) + TabStop = True + BackColor = UiTheme.CardBackground() + AccessibleRole = AccessibleRole.ListItem + AccessibleName = Describe() + ContextMenuStrip = _menu + + _actions = New FlowLayoutPanel With { + .AutoSize = True, + .AutoSizeMode = AutoSizeMode.GrowAndShrink, + .WrapContents = False, + .Margin = New Padding(0), + .BackColor = UiTheme.CardBackground() + } + Controls.Add(_actions) + + End Sub + + + Friend ReadOnly Property Item As DeadlineItem + + ' The first visible action is also Enter's. Menu-only actions appear + ' under the row's "⋯" button and its context menu. + Friend Sub AddAction(text As String, handler As Action, Optional menuOnly As Boolean = False) + + If _primary Is Nothing AndAlso Not menuOnly Then _primary = handler + _menu.Items.Add(text, Nothing, Sub(sender, e) handler()) + If menuOnly Then Return + + Dim button As New ActionButton With { + .Text = text, + .UseMnemonic = False, + .Height = UiTheme.Px(30, DeviceDpi), + .Width = TextRenderer.MeasureText(text, Font).Width + UiTheme.Px(26, DeviceDpi), + .Margin = New Padding(UiTheme.Px(6, DeviceDpi), 0, 0, 0), + .AccessibleDescription = Describe() + } + AddHandler button.Click, Sub(sender, e) handler() + _actions.Controls.Add(button) + + End Sub + + ' Adds the "⋯" button when the menu holds more than the buttons show. + ' Otherwise its space stays empty, so buttons line up down the list. + Friend Sub FinishActions() + + If _menu.Items.Count <= _actions.Controls.Count Then + _actions.Controls.Add(New Panel With { + .Size = New Size(UiTheme.Px(32, DeviceDpi), UiTheme.Px(30, DeviceDpi)), + .Margin = New Padding(UiTheme.Px(4, DeviceDpi), 0, 0, 0), + .TabStop = False + }) + Return + End If + + Dim more As New ActionButton With { + .Text = "⋯", + .Role = ActionButtonRole.Quiet, + .UseMnemonic = False, + .Height = UiTheme.Px(30, DeviceDpi), + .Width = UiTheme.Px(32, DeviceDpi), + .Margin = New Padding(UiTheme.Px(4, DeviceDpi), 0, 0, 0), + .AccessibleName = "More actions for " & Item.Title + } + AddHandler more.Click, Sub(sender, e) _menu.Show(more, New Point(0, more.Height)) + _actions.Controls.Add(more) + + End Sub + + Friend ReadOnly Property ActionTexts As IEnumerable(Of String) + Get + Return _menu.Items.Cast(Of ToolStripItem)().Select(Function(entry) entry.Text).ToList() + End Get + End Property + + ' Runs an action by its text, as a click would. + Friend Sub RunAction(text As String) + _menu.Items.Cast(Of ToolStripItem)().First(Function(entry) entry.Text = text).PerformClick() + End Sub + + Private Function Describe() As String + + Dim parts As New List(Of String) From {GroupText(), WhenText(), Item.Title, Item.ManuscriptTitle} + If Not String.IsNullOrWhiteSpace(Item.JournalName) AndAlso Not Item.Title.Contains(Item.JournalName) Then parts.Add(Item.JournalName) + If Item.ProgressTotal > 0 Then parts.Add(ProgressText()) + Return String.Join(", ", parts.Where(Function(part) Not String.IsNullOrWhiteSpace(part))) + + End Function + + Private Function GroupText() As String + Select Case Item.Group + Case DeadlineGroup.Overdue : Return "Overdue" + Case DeadlineGroup.Today : Return "Today" + Case DeadlineGroup.NoDate : Return "No date" + Case DeadlineGroup.Done : Return "Done" + Case Else : Return String.Empty + End Select + End Function + + ' "3 days ago", "today", "in 5 days"; for Done, when it was done. + Friend Function WhenText() As String + + If Item.Group = DeadlineGroup.Done AndAlso Item.CompletedDate.HasValue Then + Return "done " & Item.CompletedDate.Value.ToString("MMM d", CultureInfo.CurrentCulture) + End If + If Not Item.DueDate.HasValue Then Return "no date" + + Dim days As Integer = (Item.DueDate.Value.Date - _today).Days + Select Case days + Case 0 : Return "today" + Case -1 : Return "yesterday" + Case 1 : Return "tomorrow" + Case Is < 0 : Return (-days).ToString() & " days ago" + Case Else : Return "in " & days.ToString() & " days" + End Select + + End Function + + Private Function ProgressText() As String + If Item.Kind = DeadlineKind.Preparation Then + Return Item.ProgressDone.ToString() & " of " & Item.ProgressTotal.ToString() & " required checklist items resolved" + End If + Dim text As String = Item.ProgressDone.ToString() & " of " & Item.ProgressTotal.ToString() & + If(Item.ProgressTotal = 1, " comment addressed", " comments addressed") + If Item.ProgressActive > 0 Then text &= " · " & Item.ProgressActive.ToString() & " in progress" + Return text + End Function + + Private Function ContextText() As String + Dim text As String = Item.ManuscriptTitle + If Not String.IsNullOrWhiteSpace(Item.JournalName) AndAlso Not Item.Title.Contains(Item.JournalName) Then + text &= " · " & Item.JournalName + End If + Return text + End Function + + Private ReadOnly Property BoldFont As Font + Get + If _boldFont Is Nothing Then _boldFont = New Font(Font, FontStyle.Bold) + Return _boldFont + End Get + End Property + + Private ReadOnly Property SmallFont As Font + Get + If _smallFont Is Nothing Then _smallFont = New Font(Font.FontFamily, Font.SizeInPoints * 0.85F) + Return _smallFont + End Get + End Property + + Private ReadOnly Property DayFont As Font + Get + If _dayFont Is Nothing Then _dayFont = New Font(Font.FontFamily, Font.SizeInPoints * 1.35F, FontStyle.Bold) + Return _dayFont + End Get + End Property + + Public Overrides Function GetPreferredSize(proposedSize As Size) As Size + Dim pad As Integer = UiTheme.Px(11, DeviceDpi) + Dim height As Integer = pad * 2 + BoldFont.Height + Font.Height + UiTheme.Px(2, DeviceDpi) + If Item.ProgressTotal > 0 Then height += SmallFont.Height + UiTheme.Px(5, DeviceDpi) + Return New Size(proposedSize.Width, Math.Max(height, UiTheme.Px(64, DeviceDpi))) + End Function + + Protected Overrides Sub OnLayout(levent As LayoutEventArgs) + MyBase.OnLayout(levent) + _actions.Location = New Point(Width - _actions.Width - UiTheme.Px(12, DeviceDpi), (Height - _actions.Height) \ 2) + End Sub + + Protected Overrides Sub OnFontChanged(e As EventArgs) + For Each cached As Font In {_boldFont, _smallFont, _dayFont} + cached?.Dispose() + Next + _boldFont = Nothing : _smallFont = Nothing : _dayFont = Nothing + MyBase.OnFontChanged(e) + End Sub + + Protected Overrides Sub OnGotFocus(e As EventArgs) + MyBase.OnGotFocus(e) + Invalidate() + End Sub + + Protected Overrides Sub OnLostFocus(e As EventArgs) + MyBase.OnLostFocus(e) + Invalidate() + End Sub + + Protected Overrides Sub OnMouseDown(e As MouseEventArgs) + MyBase.OnMouseDown(e) + Focus() + End Sub + + Protected Overrides Sub OnDoubleClick(e As EventArgs) + MyBase.OnDoubleClick(e) + _primary?.Invoke() + End Sub + + Protected Overrides Function IsInputKey(keyData As Keys) As Boolean + Select Case keyData + Case Keys.Up, Keys.Down, Keys.Enter : Return True + End Select + Return MyBase.IsInputKey(keyData) + End Function + + Protected Overrides Sub OnKeyDown(e As KeyEventArgs) + MyBase.OnKeyDown(e) + Select Case e.KeyCode + Case Keys.Enter + _primary?.Invoke() + e.Handled = True + Case Keys.Up, Keys.Down + MoveFocus(e.KeyCode = Keys.Down) + e.Handled = True + End Select + End Sub + + Private Sub MoveFocus(forward As Boolean) + Dim rows As List(Of DeadlineRow) = Parent.Controls.OfType(Of DeadlineRow)().Where(Function(row) row.Visible).ToList() + Dim index As Integer = rows.IndexOf(Me) + If(forward, 1, -1) + If index >= 0 AndAlso index < rows.Count Then rows(index).Focus() + End Sub + + Protected Overrides Sub OnPaint(e As PaintEventArgs) + + Dim g As Graphics = e.Graphics + Dim dpi As Integer = DeviceDpi + g.Clear(SectionCard.SurfaceBehind(Me)) + g.SmoothingMode = SmoothingMode.AntiAlias + + Dim radius As Single = UiTheme.Px(UiTheme.CardRadius, dpi) + Using path As GraphicsPath = RoundedShapes.Create(New RectangleF(0.5F, 0.5F, Width - 1.0F, Height - 1.0F), radius) + Using fill As New SolidBrush(UiTheme.CardBackground()) + g.FillPath(fill, path) + End Using + + ' Overdue and due-today rows carry a stripe on their left edge. + Dim stripe As Color = Color.Empty + If Item.Group = DeadlineGroup.Overdue Then stripe = UiTheme.DangerColor() + If Item.Group = DeadlineGroup.Today Then stripe = UiTheme.WarningColor() + If stripe <> Color.Empty Then + Dim clip As Region = g.Clip + g.SetClip(path) + Using brush As New SolidBrush(stripe) + g.FillRectangle(brush, 0, 0, UiTheme.Px(3, dpi), Height) + End Using + g.Clip = clip + End If + + Using border As New Pen(If(Focused, UiTheme.AccentColor(), UiTheme.CardBorder()), If(Focused, 2.0F, 1.0F)) + g.DrawPath(border, path) + End Using + End Using + + Dim pad As Integer = UiTheme.Px(12, dpi) + Dim center As Integer = Height \ 2 + Const flags As TextFormatFlags = TextFormatFlags.NoPrefix Or TextFormatFlags.NoPadding Or TextFormatFlags.SingleLine Or TextFormatFlags.EndEllipsis + + ' When: month, day, and how far away. + Dim whenWidth As Integer = UiTheme.Px(62, dpi) + Dim whenInk As Color = If(Item.Group = DeadlineGroup.Overdue, UiTheme.DangerColor(), + If(Item.Group = DeadlineGroup.Today, UiTheme.WarningColor(), UiTheme.SecondaryText())) + Dim centered As TextFormatFlags = flags Or TextFormatFlags.HorizontalCenter + Dim whenTop As Integer = center - (SmallFont.Height + DayFont.Height + SmallFont.Height) \ 2 + If Item.DueDate.HasValue AndAlso Item.Group <> DeadlineGroup.Done Then + TextRenderer.DrawText(g, Item.DueDate.Value.ToString("MMM", CultureInfo.CurrentCulture).ToUpperInvariant(), SmallFont, + New Rectangle(pad, whenTop, whenWidth, SmallFont.Height), UiTheme.SecondaryText(), centered) + TextRenderer.DrawText(g, Item.DueDate.Value.Day.ToString(CultureInfo.CurrentCulture), DayFont, + New Rectangle(pad, whenTop + SmallFont.Height, whenWidth, DayFont.Height), If(Item.Group = DeadlineGroup.Next7Days OrElse Item.Group = DeadlineGroup.Later, UiTheme.PrimaryText(), whenInk), centered) + Else + TextRenderer.DrawText(g, "—", DayFont, New Rectangle(pad, whenTop + SmallFont.Height, whenWidth, DayFont.Height), UiTheme.MutedText(), centered) + End If + TextRenderer.DrawText(g, WhenText(), SmallFont, New Rectangle(pad, whenTop + SmallFont.Height + DayFont.Height, whenWidth, SmallFont.Height), whenInk, centered) + + ' Kind badge. + Dim badge As Integer = UiTheme.Px(28, dpi) + Dim badgeLeft As Integer = pad + whenWidth + UiTheme.Px(8, dpi) + Dim badgeBounds As New RectangleF(badgeLeft, center - badge / 2.0F, badge, badge) + Dim stage As PaperStage = KindStage() + Using path As GraphicsPath = RoundedShapes.Create(badgeBounds, UiTheme.Px(7, dpi)) + Using fill As New SolidBrush(UiTheme.StageBackground(stage)) + g.FillPath(fill, path) + End Using + End Using + Dim icon As Single = UiTheme.Px(16, dpi) + RailPainter.DrawGlyph(g, KindGlyph(), New RectangleF(badgeBounds.X + (badge - icon) / 2.0F, badgeBounds.Y + (badge - icon) / 2.0F, icon, icon), + UiTheme.StageForeground(stage), Math.Max(1.4F, UiTheme.Px(2, dpi) * 0.75F)) + g.SmoothingMode = SmoothingMode.Default + + ' What, and for which manuscript. + Dim textLeft As Integer = badgeLeft + badge + UiTheme.Px(12, dpi) + Dim textWidth As Integer = Math.Max(0, _actions.Left - UiTheme.Px(14, dpi) - textLeft) + Dim lines As Integer = BoldFont.Height + Font.Height + UiTheme.Px(2, dpi) + If(Item.ProgressTotal > 0, SmallFont.Height + UiTheme.Px(5, dpi), 0) + Dim y As Integer = center - lines \ 2 + TextRenderer.DrawText(g, Item.Title, BoldFont, New Rectangle(textLeft, y, textWidth, BoldFont.Height), + If(Item.Group = DeadlineGroup.Done, UiTheme.SecondaryText(), UiTheme.PrimaryText()), flags) + y += BoldFont.Height + UiTheme.Px(2, dpi) + TextRenderer.DrawText(g, ContextText(), Font, New Rectangle(textLeft, y, textWidth, Font.Height), UiTheme.SecondaryText(), flags) + y += Font.Height + + If Item.ProgressTotal > 0 Then + y += UiTheme.Px(5, dpi) + Dim barWidth As Integer = UiTheme.Px(120, dpi) + Dim barHeight As Integer = UiTheme.Px(6, dpi) + Dim barTop As Integer = y + (SmallFont.Height - barHeight) \ 2 + g.SmoothingMode = SmoothingMode.AntiAlias + Using track As GraphicsPath = RoundedShapes.Create(New RectangleF(textLeft, barTop, barWidth, barHeight), barHeight / 2.0F) + Using fill As New SolidBrush(UiTheme.SubtleBorder()) + g.FillPath(fill, track) + End Using + Dim clip As Region = g.Clip + g.SetClip(track) + Dim doneWidth As Single = barWidth * Item.ProgressDone / CSng(Item.ProgressTotal) + Dim activeWidth As Single = barWidth * Item.ProgressActive / CSng(Item.ProgressTotal) + Using doneBrush As New SolidBrush(UiTheme.SuccessColor()), activeBrush As New SolidBrush(UiTheme.AccentSecondaryColor()) + g.FillRectangle(doneBrush, textLeft, barTop, doneWidth, barHeight) + g.FillRectangle(activeBrush, textLeft + doneWidth, barTop, activeWidth, barHeight) + End Using + g.Clip = clip + End Using + g.SmoothingMode = SmoothingMode.Default + TextRenderer.DrawText(g, ProgressText(), SmallFont, + New Rectangle(textLeft + barWidth + UiTheme.Px(8, dpi), y, Math.Max(0, textWidth - barWidth - UiTheme.Px(8, dpi)), SmallFont.Height), + UiTheme.SecondaryText(), flags) + End If + + End Sub + + ' Kind colors follow the board's stage pills. + Private Function KindStage() As PaperStage + Select Case Item.Kind + Case DeadlineKind.Revision : Return PaperStage.Revision + Case DeadlineKind.FollowUp : Return PaperStage.Submitted + Case DeadlineKind.Preparation : Return PaperStage.UnderReview + Case DeadlineKind.Publication : Return PaperStage.Published + Case Else : Return PaperStage.Draft + End Select + End Function + + Private Function KindGlyph() As RailGlyph + Select Case Item.Kind + Case DeadlineKind.Revision : Return RailGlyph.Revision + Case DeadlineKind.FollowUp : Return RailGlyph.FollowUp + Case DeadlineKind.Preparation : Return RailGlyph.Checklist + Case DeadlineKind.Publication : Return RailGlyph.Publication + Case Else : Return RailGlyph.Reminders + End Select + End Function + + Protected Overrides Sub Dispose(disposing As Boolean) + If disposing Then + For Each cached As Font In {_boldFont, _smallFont, _dayFont} + cached?.Dispose() + Next + _menu.Dispose() + End If + MyBase.Dispose(disposing) + End Sub + + End Class + +End Namespace diff --git a/ManuscriptPipeline/Controls/RailItems.vb b/ManuscriptPipeline/Controls/RailItems.vb index fd1ce78..7e25b2b 100644 --- a/ManuscriptPipeline/Controls/RailItems.vb +++ b/ManuscriptPipeline/Controls/RailItems.vb @@ -16,6 +16,11 @@ Namespace Controls Help CollapseRail ExpandRail + Deadlines + Revision + FollowUp + Checklist + Publication End Enum ' Paints one left-rail item: an icon, its label, and an optional count. @@ -106,7 +111,7 @@ Namespace Controls End Sub ' Simple line icons on a 16-unit grid, after the Workspace mockup. - Private Shared Sub DrawGlyph(g As Graphics, glyph As RailGlyph, box As RectangleF, ink As Color, stroke As Single) + Friend Shared Sub DrawGlyph(g As Graphics, glyph As RailGlyph, box As RectangleF, ink As Color, stroke As Single) Dim u As Single = box.Width / 16.0F Dim x0 As Single = box.X @@ -143,6 +148,25 @@ Namespace Controls p(CSng(8 + 4.4 * Math.Cos(angle)), CSng(8 + 4.4 * Math.Sin(angle))), p(CSng(8 + 6.2 * Math.Cos(angle)), CSng(8 + 6.2 * Math.Sin(angle)))) Next + Case RailGlyph.Deadlines + g.DrawRectangle(pen, Rectangle.Round(r(2, 3, 12, 11))) + g.DrawLine(pen, p(2, 6.5F), p(14, 6.5F)) + g.DrawLine(pen, p(5.5F, 1.5F), p(5.5F, 4.5F)) + g.DrawLine(pen, p(10.5F, 1.5F), p(10.5F, 4.5F)) + Case RailGlyph.Revision + g.DrawLines(pen, {p(3, 13), p(3, 10.5F), p(10.5F, 3), p(13, 5.5F), p(5.5F, 13), p(3, 13)}) + g.DrawLine(pen, p(9, 4.5F), p(11.5F, 7)) + Case RailGlyph.FollowUp + g.DrawRectangle(pen, Rectangle.Round(r(2.5F, 4, 11, 8))) + g.DrawLines(pen, {p(2.5F, 4), p(8, 8.5F), p(13.5F, 4)}) + Case RailGlyph.Checklist + g.DrawRectangle(pen, Rectangle.Round(r(3, 3, 10, 10))) + g.DrawLines(pen, {p(5.5F, 8.2F), p(7.3F, 10), p(10.7F, 6)}) + Case RailGlyph.Publication + ' An open book. + g.DrawLines(pen, {p(8, 4.5F), p(3, 3.5F), p(3, 12), p(8, 13)}) + g.DrawLines(pen, {p(8, 4.5F), p(13, 3.5F), p(13, 12), p(8, 13)}) + g.DrawLine(pen, p(8, 4.5F), p(8, 13)) Case RailGlyph.CollapseRail g.DrawLines(pen, {p(8.5F, 4), p(4.5F, 8), p(8.5F, 12)}) g.DrawLines(pen, {p(12.5F, 4), p(8.5F, 8), p(12.5F, 12)}) diff --git a/ManuscriptPipeline/Form1.Deadlines.vb b/ManuscriptPipeline/Form1.Deadlines.vb new file mode 100644 index 0000000..53ff13b --- /dev/null +++ b/ManuscriptPipeline/Form1.Deadlines.vb @@ -0,0 +1,648 @@ +Imports System +Imports System.Collections.Generic +Imports System.Drawing +Imports System.Globalization +Imports System.IO +Imports System.Linq +Imports System.Windows.Forms +Imports ManuscriptPipeline.Controls +Imports ManuscriptPipeline.Forms +Imports ManuscriptPipeline.Models +Imports ManuscriptPipeline.Services + +' The Deadlines page (#28): what needs action, and when. Rows are rebuilt +' from the library each time; every action changes the record that owns the +' row and saves, like any other edit. +Partial Public Class Form1 + + Private deadlinesList As TableLayoutPanel = Nothing + Private deadlinesFilter As TextBox = Nothing + Private deadlinesKind As DeadlineKind? = Nothing + Private deadlinesShowDone As Boolean = False + Private ReadOnly deadlineChips As New Dictionary(Of String, FilterChip)() + Private ReadOnly deadlineGlance As New Dictionary(Of DeadlineGroup, Label)() + + ' Tests answer the date and confirmation prompts. + Friend deadlineDatePrompt As Func(Of DeadlineItem, DateTime?) = Nothing + Friend deadlineConfirmPrompt As Func(Of String, Boolean) = Nothing + + Private Shared ReadOnly OpenGroups As DeadlineGroup() = { + DeadlineGroup.Overdue, DeadlineGroup.Today, DeadlineGroup.Next7Days, DeadlineGroup.Later, DeadlineGroup.NoDate + } + + + Private Function BuildDeadlinesPage() As Control + + Dim dpi As Integer = DeviceDpi + + deadlinesFilter = New TextBox With { + .PlaceholderText = "Filter by manuscript or journal", + .BorderStyle = BorderStyle.None, + .BackColor = UiTheme.CardBackground(), + .ForeColor = UiTheme.PrimaryText(), + .AccessibleName = "Filter deadlines" + } + deadlinesFilter.Width = Math.Max(UiTheme.Px(240, dpi), TextRenderer.MeasureText(deadlinesFilter.PlaceholderText, Me.Font).Width + UiTheme.Px(16, dpi)) + AddHandler deadlinesFilter.TextChanged, Sub(sender, e) FillDeadlines() + + Dim btnExport As New ActionButton With { + .Text = "Export Calendar...", + .Width = GetResponsiveButtonWidth("Export Calendar...", 140), + .Height = GetResponsiveButtonHeight(34), + .AccessibleDescription = "Saves the dated items as an .ics file for Outlook, Google Calendar, or Apple Calendar." + } + AddHandler btnExport.Click, AddressOf ExportDeadlineCalendar + + Dim btnAdd As New ActionButton With { + .Text = "+ Add Reminder", + .Role = ActionButtonRole.Primary, + .AccessibleName = "Add Reminder", + .Width = GetResponsiveButtonWidth("+ Add Reminder", 140), + .Height = GetResponsiveButtonHeight(34) + } + AddHandler btnAdd.Click, AddressOf AddDeadlineReminder + + Dim frame As TableLayoutPanel = CreatePageFrame( + "Deadlines", + DateTime.Today.ToString("dddd, MMMM d, yyyy", CultureInfo.CurrentCulture) & + ". Revision deadlines, journal follow-ups, your reminders, unfinished preparation, and possible publications.", + CreateSearchField(deadlinesFilter), btnExport, btnAdd) + + Dim body As New TableLayoutPanel With { + .Dock = DockStyle.Fill, + .ColumnCount = 1, + .RowCount = 3, + .Margin = New Padding(0), + .BackColor = UiTheme.BoardBackground() + } + body.ColumnStyles.Add(New ColumnStyle(SizeType.Percent, 100.0F)) + body.RowStyles.Add(New RowStyle(SizeType.AutoSize)) + body.RowStyles.Add(New RowStyle(SizeType.AutoSize)) + body.RowStyles.Add(New RowStyle(SizeType.Percent, 100.0F)) + + body.Controls.Add(BuildDeadlineGlance(), 0, 0) + body.Controls.Add(BuildDeadlineChips(), 0, 1) + + Dim scroller As New Panel With { + .Dock = DockStyle.Fill, + .AutoScroll = True, + .Margin = New Padding(0), + .BackColor = UiTheme.BoardBackground(), + .AccessibleName = "Deadlines", + .AccessibleRole = AccessibleRole.List + } + deadlinesList = New TableLayoutPanel With { + .Dock = DockStyle.Top, + .AutoSize = True, + .AutoSizeMode = AutoSizeMode.GrowAndShrink, + .ColumnCount = 1, + .Margin = New Padding(0), + .Padding = New Padding(0, 0, UiTheme.Px(4, dpi), UiTheme.Px(12, dpi)), + .BackColor = UiTheme.BoardBackground() + } + deadlinesList.ColumnStyles.Add(New ColumnStyle(SizeType.Percent, 100.0F)) + scroller.Controls.Add(deadlinesList) + body.Controls.Add(scroller, 0, 2) + + frame.Controls.Add(body, 0, 2) + FillDeadlines() + Return frame + + End Function + + + ' Four counts across the top; they ignore the filters. + Private Function BuildDeadlineGlance() As Control + + Dim dpi As Integer = DeviceDpi + Dim strip As New FlowLayoutPanel With { + .AutoSize = True, + .AutoSizeMode = AutoSizeMode.GrowAndShrink, + .WrapContents = True, + .Dock = DockStyle.Top, + .Margin = New Padding(0, 0, 0, UiTheme.Px(10, dpi)), + .BackColor = UiTheme.BoardBackground(), + .AccessibleName = "At a glance" + } + + deadlineGlance.Clear() + + For Each tile In { + (DeadlineGroup.Overdue, "overdue", UiTheme.DangerColor()), + (DeadlineGroup.Today, "due today", UiTheme.WarningColor()), + (DeadlineGroup.Next7Days, "in the next 7 days", UiTheme.PrimaryText()), + (DeadlineGroup.NoDate, "without a date", UiTheme.SecondaryText()) + } + Dim panel As New RoundedPanel With { + .BackColor = UiTheme.CardBackground(), + .BorderColor = UiTheme.CardBorder(), + .BorderThickness = 1.0F, + .CornerRadius = UiTheme.Px(UiTheme.ControlRadius, dpi), + .Padding = New Padding(UiTheme.Px(12, dpi), UiTheme.Px(8, dpi), UiTheme.Px(12, dpi), UiTheme.Px(8, dpi)), + .Margin = New Padding(0, 0, UiTheme.Px(10, dpi), 0) + } + Dim count As New Label With { + .AutoSize = True, + .Text = "0", + .Font = New Font(Me.Font.FontFamily, Me.Font.SizeInPoints * 1.45F, FontStyle.Bold), + .ForeColor = tile.Item3, + .BackColor = UiTheme.CardBackground(), + .Location = New Point(panel.Padding.Left, panel.Padding.Top) + } + Dim caption As New Label With { + .AutoSize = True, + .Text = tile.Item2, + .ForeColor = UiTheme.SecondaryText(), + .BackColor = UiTheme.CardBackground() + } + panel.Controls.Add(count) + panel.Controls.Add(caption) + caption.Location = New Point(panel.Padding.Left, count.Bottom) + panel.Size = New Size( + Math.Max(UiTheme.Px(132, dpi), TextRenderer.MeasureText(tile.Item2, Me.Font).Width + panel.Padding.Horizontal), + caption.Bottom + panel.Padding.Bottom) + deadlineGlance(tile.Item1) = count + strip.Controls.Add(panel) + Next + + Return strip + + End Function + + + Private Function BuildDeadlineChips() As Control + + Dim dpi As Integer = DeviceDpi + Dim row As New FlowLayoutPanel With { + .AutoSize = True, + .AutoSizeMode = AutoSizeMode.GrowAndShrink, + .WrapContents = True, + .Dock = DockStyle.Top, + .Margin = New Padding(0, 0, 0, UiTheme.Px(4, dpi)), + .BackColor = UiTheme.BoardBackground(), + .AccessibleName = "Show" + } + + deadlineChips.Clear() + + For Each entry In { + ("All", CType(Nothing, DeadlineKind?)), + ("Revisions", CType(DeadlineKind.Revision, DeadlineKind?)), + ("Follow-ups", CType(DeadlineKind.FollowUp, DeadlineKind?)), + ("Reminders", CType(DeadlineKind.Reminder, DeadlineKind?)), + ("Preparation", CType(DeadlineKind.Preparation, DeadlineKind?)), + ("Publications", CType(DeadlineKind.Publication, DeadlineKind?)) + } + Dim kind As DeadlineKind? = entry.Item2 + Dim chip As New FilterChip With { + .Text = entry.Item1, + .Margin = New Padding(0, 0, UiTheme.Px(8, dpi), UiTheme.Px(6, dpi)) + } + AddHandler chip.Click, + Sub(sender, e) + deadlinesKind = If(Nullable.Equals(deadlinesKind, kind), Nothing, kind) + FillDeadlines() + End Sub + deadlineChips(entry.Item1) = chip + row.Controls.Add(chip) + Next + + Return row + + End Function + + + Private Sub FillDeadlines() + + If deadlinesList Is Nothing OrElse deadlinesList.IsDisposed Then + Return + End If + + Dim today As DateTime = DateTime.Today + Dim all As List(Of DeadlineItem) = DeadlineService.Build(manuscripts, today) + Dim open As List(Of DeadlineItem) = all.Where(Function(item) item.Group <> DeadlineGroup.Done).ToList() + + For Each tile In deadlineGlance + tile.Value.Text = open.Where(Function(item) item.Group = tile.Key).Count().ToString(CultureInfo.CurrentCulture) + Next + + For Each chip In deadlineChips + Dim kind As DeadlineKind? = ChipKind(chip.Key) + Dim count As Integer = open.Where(Function(item) Not kind.HasValue OrElse item.Kind = kind.Value).Count() + chip.Value.Text = chip.Key & " " & count.ToString(CultureInfo.CurrentCulture) + chip.Value.Tone = If(Nullable.Equals(deadlinesKind, kind), FilterChipTone.Active, FilterChipTone.Neutral) + ' Possible publications exist only after a publication check. + If kind = DeadlineKind.Publication Then chip.Value.Visible = count > 0 OrElse Nullable.Equals(deadlinesKind, kind) + Next + + Dim filter As String = If(deadlinesFilter?.Text, String.Empty).Trim() + Dim shown As List(Of DeadlineItem) = all.Where( + Function(item) (Not deadlinesKind.HasValue OrElse item.Kind = deadlinesKind.Value) AndAlso + (filter.Length = 0 OrElse + {item.ManuscriptTitle, item.JournalName, item.Title}.Any( + Function(value) If(value, String.Empty).IndexOf(filter, StringComparison.CurrentCultureIgnoreCase) >= 0))).ToList() + + ' Keep the keyboard where it was across a rebuild. + Dim focusedIndex As Integer = + deadlinesList.Controls.OfType(Of DeadlineRow)().ToList().FindIndex(Function(row) row.ContainsFocus) + + deadlinesList.SuspendLayout() + For Each child As Control In deadlinesList.Controls.Cast(Of Control)().ToList() + deadlinesList.Controls.Remove(child) + child.Dispose() + Next + deadlinesList.RowStyles.Clear() + deadlinesList.RowCount = 0 + + For Each group As DeadlineGroup In OpenGroups + Dim items As List(Of DeadlineItem) = shown.Where(Function(item) item.Group = group).ToList() + If items.Count = 0 Then Continue For + AddDeadlineHeading(GroupHeading(group), items.Count, group) + For Each item As DeadlineItem In items + AddDeadlineRow(CreateDeadlineRow(item, today)) + Next + Next + + If Not shown.Any(Function(item) item.Group <> DeadlineGroup.Done) Then + AddDeadlineRow(New Label With { + .Text = If(open.Count = 0, + "Nothing needs action right now. Revision deadlines, journal follow-ups, your reminders, and unfinished submission preparation appear here.", + "Nothing matches this filter."), + .AutoSize = True, + .UseMnemonic = False, + .ForeColor = UiTheme.SecondaryText(), + .Margin = New Padding(0, UiTheme.Px(18, DeviceDpi), 0, UiTheme.Px(18, DeviceDpi)) + }) + End If + + Dim done As List(Of DeadlineItem) = shown.Where(Function(item) item.Group = DeadlineGroup.Done).ToList() + If done.Count > 0 Then + AddDoneHeading(done.Count) + If deadlinesShowDone Then + For Each item As DeadlineItem In done + AddDeadlineRow(CreateDeadlineRow(item, today)) + Next + End If + End If + + deadlinesList.ResumeLayout(True) + + Dim rows As List(Of DeadlineRow) = deadlinesList.Controls.OfType(Of DeadlineRow)().ToList() + If focusedIndex >= 0 AndAlso rows.Count > 0 Then + rows(Math.Min(focusedIndex, rows.Count - 1)).Focus() + End If + + End Sub + + + Private Shared Function ChipKind(name As String) As DeadlineKind? + Select Case name + Case "Revisions" : Return DeadlineKind.Revision + Case "Follow-ups" : Return DeadlineKind.FollowUp + Case "Reminders" : Return DeadlineKind.Reminder + Case "Preparation" : Return DeadlineKind.Preparation + Case "Publications" : Return DeadlineKind.Publication + Case Else : Return Nothing + End Select + End Function + + + Private Shared Function GroupHeading(group As DeadlineGroup) As String + Select Case group + Case DeadlineGroup.Overdue : Return "OVERDUE" + Case DeadlineGroup.Today : Return "TODAY" + Case DeadlineGroup.Next7Days : Return "NEXT 7 DAYS" + Case DeadlineGroup.Later : Return "LATER" + Case Else : Return "NO DATE" + End Select + End Function + + + Private Sub AddDeadlineRow(control As Control) + control.Anchor = AnchorStyles.Left Or AnchorStyles.Right + If TypeOf control Is DeadlineRow Then + control.Margin = New Padding(0, 0, 0, UiTheme.Px(6, DeviceDpi)) + control.Height = control.GetPreferredSize(Size.Empty).Height + End If + deadlinesList.RowStyles.Add(New RowStyle(SizeType.AutoSize)) + deadlinesList.Controls.Add(control, 0, deadlinesList.RowCount) + deadlinesList.RowCount += 1 + End Sub + + + Private Sub AddDeadlineHeading(text As String, count As Integer, group As DeadlineGroup) + AddDeadlineRow(New Label With { + .Text = text & " " & count.ToString(CultureInfo.CurrentCulture), + .AutoSize = True, + .UseMnemonic = False, + .Font = attentionTitleFont, + .ForeColor = If(group = DeadlineGroup.Overdue, UiTheme.DangerColor(), + If(group = DeadlineGroup.Today, UiTheme.WarningColor(), UiTheme.MutedText())), + .Margin = New Padding(0, UiTheme.Px(10, DeviceDpi), 0, UiTheme.Px(6, DeviceDpi)), + .AccessibleRole = AccessibleRole.StaticText + }) + End Sub + + + ' Done stays collapsed behind Show. + Private Sub AddDoneHeading(count As Integer) + Dim link As New LinkLabel With { + .Text = "Done in the last " & DeadlineService.DoneDays.ToString(CultureInfo.CurrentCulture) & " days: " & + count.ToString(CultureInfo.CurrentCulture) & " · " & If(deadlinesShowDone, "Hide", "Show"), + .AutoSize = True, + .UseMnemonic = False, + .LinkColor = UiTheme.AccentColor(), + .ActiveLinkColor = UiTheme.AccentColor(), + .VisitedLinkColor = UiTheme.AccentColor(), + .ForeColor = UiTheme.SecondaryText(), + .Margin = New Padding(0, UiTheme.Px(12, DeviceDpi), 0, UiTheme.Px(6, DeviceDpi)), + .AccessibleName = If(deadlinesShowDone, "Hide done items", "Show done items") + } + link.LinkArea = New LinkArea(link.Text.Length - 4, 4) + AddHandler link.LinkClicked, + Sub(sender, e) + deadlinesShowDone = Not deadlinesShowDone + FillDeadlines() + End Sub + AddDeadlineRow(link) + End Sub + + + ' Each kind has its own actions; the first is also Enter's. + Private Function CreateDeadlineRow(item As DeadlineItem, today As DateTime) As DeadlineRow + + Dim row As New DeadlineRow(item, today) + + Select Case True + Case item.Group = DeadlineGroup.Done + row.AddAction("Open Manuscript", Sub() OpenDeadline(item), menuOnly:=True) + + Case item.Kind = DeadlineKind.Revision AndAlso Not item.DueDate.HasValue + row.AddAction("Set Deadline...", Sub() PostponeDeadline(item)) + row.AddAction("Open", Sub() OpenDeadline(item)) + + Case item.Kind = DeadlineKind.Revision + row.AddAction("Open", Sub() OpenDeadline(item)) + row.AddAction("Postpone...", Sub() PostponeDeadline(item)) + + Case item.Kind = DeadlineKind.FollowUp + row.AddAction("Open", Sub() OpenDeadline(item)) + row.AddAction("Postpone...", Sub() PostponeDeadline(item)) + row.AddAction("Clear Follow-up...", Sub() ClearDeadlineFollowUp(item), menuOnly:=True) + + Case item.Kind = DeadlineKind.Reminder + row.AddAction("Done", Sub() CompleteDeadlineReminder(item)) + row.AddAction("Postpone...", Sub() PostponeDeadline(item)) + row.AddAction("Edit Reminder...", Sub() EditDeadlineReminder(item), menuOnly:=True) + row.AddAction("Open Manuscript", Sub() OpenDeadline(item), menuOnly:=True) + + Case item.Kind = DeadlineKind.Publication + row.AddAction("Review Match", Sub() ReviewDeadlineMatch(item)) + row.AddAction("Mark Published...", Sub() MarkDeadlinePublished(item)) + row.AddAction("Ignore Match", Sub() IgnoreDeadlineMatch(item), menuOnly:=True) + row.AddAction("Open Manuscript", Sub() OpenDeadline(item), menuOnly:=True) + + Case Else + row.AddAction("Open Readiness", Sub() OpenDeadline(item)) + + End Select + + row.FinishActions() + Return row + + End Function + + + ' Opens the manuscript where the item lives: a submission (at its reviewer + ' responses for a revision with comments), or Readiness & Packets. + Private Sub OpenDeadline(item As DeadlineItem) + + Dim manuscript As Manuscript = FindManuscript(item.ManuscriptId) + If manuscript Is Nothing Then Return + + OpenManuscript(manuscript) + + If currentPage <> WorkspacePage.Manuscript OrElse manuscriptEditor Is Nothing OrElse manuscriptEditor.IsDisposed Then + Return + End If + + Select Case item.Kind + Case DeadlineKind.Revision, DeadlineKind.FollowUp + If item.SubmissionId.HasValue Then + manuscriptEditor.ShowSubmission(item.SubmissionId.Value, showResponses:=item.Kind = DeadlineKind.Revision AndAlso item.ProgressTotal > 0) + End If + Case DeadlineKind.Preparation + manuscriptEditor.ShowReadinessAndPackets() + End Select + + End Sub + + + Private Sub PostponeDeadline(item As DeadlineItem) + + Dim chosen As DateTime? = PromptDeadlineDate(item) + If Not chosen.HasValue Then Return + + Dim dateText As String = chosen.Value.ToString("MMM d", CultureInfo.CurrentCulture) + ChangeDeadlineDate(item, chosen, + If(item.DueDate.HasValue, "Moved """ & item.Title & """ to " & dateText & ".", + "Set the revision deadline to " & dateText & ".")) + + End Sub + + + Private Function PromptDeadlineDate(item As DeadlineItem) As DateTime? + + If deadlineDatePrompt IsNot Nothing Then + Return deadlineDatePrompt(item) + End If + + Dim heading As String + Dim note As String + Dim verb As String = "Postpone to" + + Select Case item.Kind + Case DeadlineKind.FollowUp + heading = "Postpone the follow-up" + note = "This changes the follow-up date on the " & If(String.IsNullOrWhiteSpace(item.JournalName), "journal", item.JournalName) & + " submission. The manuscript page shows the same date." + Case DeadlineKind.Revision + If item.DueDate.HasValue Then + heading = "Postpone the revision deadline" + note = "This changes the revision deadline on the editorial decision. The manuscript page shows the same date." + Else + heading = "Set the revision deadline" + note = "The deadline is saved on the latest editorial decision, as if you had entered it there." + verb = "Set to" + End If + Case Else + heading = "Postpone the reminder" + note = "This changes the reminder's date." + End Select + + Using dialog As New DeadlineDateForm(heading, item.Title & " · " & item.ManuscriptTitle, note, verb, item.DueDate, DateTime.Today) + If dialog.ShowDialog(Me) <> DialogResult.OK Then Return Nothing + Return dialog.SelectedDate + End Using + + End Function + + + ' Changes the owning record, saves, and puts the old date back if the + ' save fails. + Private Sub ChangeDeadlineDate(item As DeadlineItem, newDate As DateTime?, outcome As String) + + Dim manuscript As Manuscript = FindManuscript(item.ManuscriptId) + If manuscript Is Nothing Then Return + + Dim previous As DateTime? + Try + previous = DeadlineService.SetDate(manuscript, item, newDate) + Catch ex As InvalidOperationException + MessageBox.Show(Me, ex.Message, "Deadlines", MessageBoxButtons.OK, MessageBoxIcon.Information) + FillDeadlines() + Return + End Try + + If Not SaveManuscripts() Then + DeadlineService.SetDate(manuscript, item, previous) + FillDeadlines() + Return + End If + + RenderManuscripts() + lblStatus.Text = outcome + + End Sub + + + Private Sub ClearDeadlineFollowUp(item As DeadlineItem) + + Dim question As String = + "Clear the follow-up date for " & If(String.IsNullOrWhiteSpace(item.JournalName), "this submission", item.JournalName) & "?" & + Environment.NewLine & Environment.NewLine & + "The submission stays as it is; only its follow-up date is removed." + + Dim confirmed As Boolean = + If(deadlineConfirmPrompt IsNot Nothing, + deadlineConfirmPrompt(question), + MessageBox.Show(Me, question, "Clear Follow-up", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2) = DialogResult.Yes) + + If confirmed Then + ChangeDeadlineDate(item, Nothing, "Cleared the follow-up for " & If(String.IsNullOrWhiteSpace(item.JournalName), "the submission", item.JournalName) & ".") + End If + + End Sub + + + Private Sub CompleteDeadlineReminder(item As DeadlineItem) + + Dim manuscript As Manuscript = FindManuscript(item.ManuscriptId) + If manuscript Is Nothing Then Return + + Dim reminder As ManuscriptReminder + Try + reminder = DeadlineService.Complete(manuscript, item, DateTime.Today) + Catch ex As InvalidOperationException + MessageBox.Show(Me, ex.Message, "Deadlines", MessageBoxButtons.OK, MessageBoxIcon.Information) + FillDeadlines() + Return + End Try + + If Not SaveManuscripts() Then + reminder.IsCompleted = False + reminder.CompletedDate = Nothing + FillDeadlines() + Return + End If + + RenderManuscripts() + lblStatus.Text = "Marked """ & item.Title & """ done." + + End Sub + + + Private Sub AddDeadlineReminder(sender As Object, e As EventArgs) + + If manuscripts.Count = 0 Then + MessageBox.Show(Me, "Add a manuscript before creating a reminder.", "No Manuscripts", MessageBoxButtons.OK, MessageBoxIcon.Information) + Return + End If + + Using dialog As New ReminderEditForm(manuscripts) + If dialog.ShowDialog(Me) <> DialogResult.OK OrElse dialog.Result Is Nothing Then Return + + Dim manuscript As Manuscript = FindManuscript(dialog.SelectedManuscriptId) + If manuscript Is Nothing Then Return + If manuscript.Reminders Is Nothing Then manuscript.Reminders = New List(Of ManuscriptReminder)() + + manuscript.Reminders.Add(dialog.Result) + If Not SaveManuscripts() Then + manuscript.Reminders.Remove(dialog.Result) + Return + End If + End Using + + RenderManuscripts() + + End Sub + + + Private Sub EditDeadlineReminder(item As DeadlineItem) + + Dim manuscript As Manuscript = FindManuscript(item.ManuscriptId) + If manuscript Is Nothing OrElse manuscript.Reminders Is Nothing OrElse Not item.ReminderId.HasValue Then Return + + Dim index As Integer = manuscript.Reminders.FindIndex(Function(reminder) reminder IsNot Nothing AndAlso reminder.Id = item.ReminderId.Value) + If index < 0 Then Return + + Dim existing As ManuscriptReminder = manuscript.Reminders(index) + + Using dialog As New ReminderEditForm(manuscripts, manuscript, existing) + If dialog.ShowDialog(Me) <> DialogResult.OK OrElse dialog.Result Is Nothing Then Return + + manuscript.Reminders(index) = dialog.Result + If Not SaveManuscripts() Then + manuscript.Reminders(index) = existing + Return + End If + End Using + + RenderManuscripts() + + End Sub + + + ' The dated items as an .ics file; undated work has no calendar date. + Private Sub ExportDeadlineCalendar(sender As Object, e As EventArgs) + + Dim dated As List(Of ReminderOccurrence) = ReminderService.BuildOccurrences(manuscripts, DateTime.Today) + + If dated.Count = 0 Then + MessageBox.Show(Me, "There are no dated items to export.", "No Calendar Events", MessageBoxButtons.OK, MessageBoxIcon.Information) + Return + End If + + Using picker As New SaveFileDialog With { + .Title = "Export PaperRoute Deadlines", + .Filter = "iCalendar file (*.ics)|*.ics", + .DefaultExt = "ics", + .AddExtension = True, + .OverwritePrompt = True, + .FileName = "PaperRoute-Deadlines.ics" + } + If picker.ShowDialog(Me) <> DialogResult.OK Then Return + + Try + File.WriteAllText(picker.FileName, IcsCalendarService.Export(dated)) + lblStatus.Text = "Exported " & dated.Count.ToString(CultureInfo.CurrentCulture) & + If(dated.Count = 1, " dated item", " dated items") & " to " & Path.GetFileName(picker.FileName) & "." + Catch ex As Exception + MessageBox.Show(Me, "PaperRoute could not create the calendar file." & Environment.NewLine & Environment.NewLine & ex.Message, + "Calendar Export Error", MessageBoxButtons.OK, MessageBoxIcon.Error) + End Try + End Using + + End Sub + +End Class diff --git a/ManuscriptPipeline/Form1.ManuscriptPage.vb b/ManuscriptPipeline/Form1.ManuscriptPage.vb index a9a868c..bf86fd7 100644 --- a/ManuscriptPipeline/Form1.ManuscriptPage.vb +++ b/ManuscriptPipeline/Form1.ManuscriptPage.vb @@ -89,7 +89,7 @@ Partial Public Class Form1 Select Case page Case WorkspacePage.Library : Return "Library" Case WorkspacePage.Journals : Return "Journals" - Case WorkspacePage.Reminders : Return "Reminders" + Case WorkspacePage.Deadlines : Return "Deadlines" Case WorkspacePage.ImportExport : Return "Import & Export" Case Else : Return "Board" End Select @@ -260,6 +260,8 @@ Partial Public Class Form1 End Sub Dim pageMenu As New ContextMenuStrip() + pageMenu.Items.Add("Check for Publication...", Nothing, Sub(sender, e) CheckOpenManuscriptForPublication()) + pageMenu.Items.Add(New ToolStripSeparator()) Dim deleteItem As ToolStripItem = pageMenu.Items.Add( "Delete Manuscript...", @@ -584,6 +586,29 @@ Partial Public Class Form1 End Sub + ' A check can mark the saved manuscript published, so the page must + ' match the library first; afterwards it shows the saved version. + Private Sub CheckOpenManuscriptForPublication() + + If manuscriptEditor Is Nothing OrElse manuscriptEditor.IsDisposed Then Return + + If manuscriptEditor.HasUnsavedChanges() Then + MessageBox.Show(Me, "Save or discard your changes to this manuscript first, then check for a publication.", + "Check for Publication", MessageBoxButtons.OK, MessageBoxIcon.Information) + Return + End If + + Dim request As PageRequest = CurrentRequest + CheckForPublications({currentManuscriptId}) + + If currentPage = WorkspacePage.Manuscript AndAlso FindManuscript(currentManuscriptId) IsNot Nothing Then + LeaveCurrentPage() + ShowPage(request) + End If + + End Sub + + Private Sub ManuscriptDeleteConfirmed(sender As Object, e As EventArgs) Dim manuscript As Manuscript = FindManuscript(currentManuscriptId) diff --git a/ManuscriptPipeline/Form1.Publications.vb b/ManuscriptPipeline/Form1.Publications.vb new file mode 100644 index 0000000..a4f322b --- /dev/null +++ b/ManuscriptPipeline/Form1.Publications.vb @@ -0,0 +1,118 @@ +Imports System +Imports System.Collections.Generic +Imports System.Globalization +Imports System.Linq +Imports System.Windows.Forms +Imports ManuscriptPipeline.Forms +Imports ManuscriptPipeline.Models +Imports ManuscriptPipeline.Services + +' The publication check and Fill Blanks (#61): only when the user asks, +' from Import & Export or a possible publication on the Deadlines page. +Partial Public Class Form1 + + ' Tests answer with synthetic records instead of Crossref and ORCID. + Friend publicationSource As IPublicationSource = Nothing + Friend markPublishedPrompt As Func(Of Manuscript, PublicationMatch, Boolean) = Nothing + + Private Function CurrentPublicationSource() As IPublicationSource + If publicationSource Is Nothing Then publicationSource = New OnlinePublicationSource() + Return publicationSource + End Function + + + ' The ORCID iD of the author marked "This is me", if any. + Private Function OwnOrcid() As String + Dim self As AuthorRecord = If(authorLibrary?.Authors, New List(Of AuthorRecord)()). + FirstOrDefault(Function(author) author IsNot Nothing AndAlso author.IsMe AndAlso Not String.IsNullOrWhiteSpace(author.Orcid)) + Return If(self?.Orcid, String.Empty) + End Function + + + Private Sub CheckForPublications(sender As Object, e As EventArgs) + CheckForPublications(Nothing) + End Sub + + + Friend Sub CheckForPublications(manuscriptIds As IEnumerable(Of Guid)) + + Using dialog As New PublicationCheckForm(manuscripts, manuscriptIds, OwnOrcid(), CurrentPublicationSource(), AddressOf SaveManuscripts, DateTime.Today) + dialog.ConfirmMarkPublished = markPublishedPrompt + dialog.ShowDialog(Me) + If dialog.Changed Then + RenderManuscripts() + End If + End Using + + End Sub + + + Private Sub FillBlanksFromCrossref(sender As Object, e As EventArgs) + + Using dialog As New FillBlanksForm(manuscripts, CurrentPublicationSource(), AddressOf SaveManuscripts) + If dialog.ShowDialog(Me) = DialogResult.OK AndAlso dialog.FilledCount > 0 Then + RenderManuscripts() + lblStatus.Text = "Filled " & dialog.FilledCount.ToString(CultureInfo.CurrentCulture) & + If(dialog.FilledCount = 1, " empty field", " empty fields") & " from Crossref." + End If + End Using + + End Sub + + + Private Function FindPublicationMatch(item As DeadlineItem, ByRef manuscript As Manuscript) As PublicationMatch + manuscript = FindManuscript(item.ManuscriptId) + If manuscript Is Nothing OrElse Not item.PublicationMatchId.HasValue OrElse manuscript.PublicationMatches Is Nothing Then Return Nothing + Dim id As Guid = item.PublicationMatchId.Value + Return manuscript.PublicationMatches.FirstOrDefault(Function(match) match IsNot Nothing AndAlso match.Id = id) + End Function + + + Private Sub ReviewDeadlineMatch(item As DeadlineItem) + Dim manuscript As Manuscript = Nothing + Dim match As PublicationMatch = FindPublicationMatch(item, manuscript) + If match IsNot Nothing Then PublicationActions.OpenMatch(Me, match) + End Sub + + + Private Sub MarkDeadlinePublished(item As DeadlineItem) + + Dim manuscript As Manuscript = Nothing + Dim match As PublicationMatch = FindPublicationMatch(item, manuscript) + If match Is Nothing Then Return + + Dim confirmed As Boolean + If markPublishedPrompt IsNot Nothing Then + confirmed = markPublishedPrompt(manuscript, match) + Else + Using dialog As New MarkPublishedForm(manuscript, match, DateTime.Today) + confirmed = dialog.ShowDialog(Me) = DialogResult.OK + End Using + End If + If Not confirmed Then Return + + Dim target As Manuscript = manuscript + If PublicationActions.Commit(manuscripts, target, Sub() PublicationMatchService.MarkPublished(target, match, DateTime.Today), AddressOf SaveManuscripts) Then + RenderManuscripts() + lblStatus.Text = "Marked """ & ReminderService.SafeManuscriptTitle(target) & """ published." + Else + RenderManuscripts() + End If + + End Sub + + + Private Sub IgnoreDeadlineMatch(item As DeadlineItem) + + Dim manuscript As Manuscript = Nothing + Dim match As PublicationMatch = FindPublicationMatch(item, manuscript) + If match Is Nothing Then Return + + If PublicationActions.Commit(manuscripts, manuscript, Sub() PublicationMatchService.Ignore(match), AddressOf SaveManuscripts) Then + lblStatus.Text = "Ignored the possible publication. Later checks will not show it again." + End If + RenderManuscripts() + + End Sub + +End Class diff --git a/ManuscriptPipeline/Form1.Shell.vb b/ManuscriptPipeline/Form1.Shell.vb index 559b713..8d20763 100644 --- a/ManuscriptPipeline/Form1.Shell.vb +++ b/ManuscriptPipeline/Form1.Shell.vb @@ -19,7 +19,7 @@ Partial Public Class Form1 Board Library Journals - Reminders + Deadlines ImportExport ' Not in the rail: a manuscript opened from another page. Manuscript @@ -192,7 +192,7 @@ Partial Public Class Form1 (WorkspacePage.Board, RailGlyph.Board, "Board"), (WorkspacePage.Library, RailGlyph.Library, "Library"), (WorkspacePage.Journals, RailGlyph.Journals, "Journals"), - (WorkspacePage.Reminders, RailGlyph.Reminders, "Reminders"), + (WorkspacePage.Deadlines, RailGlyph.Deadlines, "Deadlines"), (WorkspacePage.ImportExport, RailGlyph.ImportExport, "Import & Export") } Dim page As WorkspacePage = entry.Item1 @@ -362,7 +362,7 @@ Partial Public Class Form1 End Sub - ' Library size and due reminders, refreshed with the board. + ' Library size and deadlines due now, refreshed with the board. Private Sub UpdateRailCounts() Dim count As Integer = manuscripts.Count @@ -371,15 +371,11 @@ Partial Public Class Form1 If(StorageEnvironment.IsDevelopmentProfile(), "Development profile" & Environment.NewLine, String.Empty) & count.ToString() & If(count = 1, " manuscript", " manuscripts") - Dim reminders As RailButton = Nothing + ' Overdue and due-today items: the ones to act on now. + Dim deadlines As RailButton = Nothing - If railButtons.TryGetValue(WorkspacePage.Reminders, reminders) Then - reminders.Badge = - ReminderService.NotificationCandidates( - manuscripts, - DateTime.Today, - appSettings.ReminderNotificationDaysAhead - ).Count + If railButtons.TryGetValue(WorkspacePage.Deadlines, deadlines) Then + deadlines.Badge = DeadlineService.CountDueNow(manuscripts, DateTime.Today) End If End Sub @@ -537,6 +533,8 @@ Partial Public Class Form1 libraryContent = Nothing libraryAuthorsForm = Nothing libraryFilter = Nothing + deadlinesList = Nothing + deadlinesFilter = Nothing ' Hosted editors save as they go. Refresh the board's copies, as the ' board did after their dialogs closed. @@ -570,11 +568,8 @@ Partial Public Class Form1 "Journals", "Your reusable journal records and their submission checklists. Changes are saved as you make them.", New JournalLibraryForm(manuscripts)) - Case WorkspacePage.Reminders - view = BuildHostedPage( - "Reminders", - "Revision deadlines, follow-ups, and your own reminders, with calendar export. Changes are saved as you make them.", - New RemindersForm(manuscripts, repository)) + Case WorkspacePage.Deadlines + view = BuildDeadlinesPage() Case Else view = BuildImportExportPage() End Select @@ -1023,9 +1018,12 @@ Partial Public Class Form1 ' Keeps an open Library page in step with the board after edits. Private Sub RefreshOpenPage() - If currentPage = WorkspacePage.Library Then - FillLibraryGrid() - End If + Select Case currentPage + Case WorkspacePage.Library + FillLibraryGrid() + Case WorkspacePage.Deadlines + FillDeadlines() + End Select End Sub @@ -1122,6 +1120,10 @@ Partial Public Class Form1 addCommand("Export Library as RIS...", "The same, in RIS format.", AddressOf ExportRis) addCommand("Publication & CV Export...", "Formatted lists of your work for a CV or report.", AddressOf OpenPublicationExport) + addSection("Keep records current") + addCommand("Check for Publications...", "Asks Crossref, and your ORCID record if you like, whether manuscripts you are tracking have been published. Only when you ask; nothing changes until you choose Mark Published.", AddressOf CheckForPublications) + addCommand("Fill Blanks from Crossref...", "For manuscripts with a DOI: fills empty fields such as the journal, date, volume, and pages, after showing every change. It never replaces a value.", AddressOf FillBlanksFromCrossref) + addSection("Keep it safe") addCommand("Backup Library...", "A ZIP of the complete library, including files PaperRoute manages. The only format that keeps everything.", AddressOf BackupLibrary) addCommand("Restore Backup...", "Replaces the current library with a backup after showing what it contains. A safety backup of the current library is made first.", AddressOf RestoreLibraryBackup) diff --git a/ManuscriptPipeline/Form1.vb b/ManuscriptPipeline/Form1.vb index a54536d..5b3ddac 100644 --- a/ManuscriptPipeline/Form1.vb +++ b/ManuscriptPipeline/Form1.vb @@ -363,6 +363,22 @@ Public Class Form1 attentionBar.Controls.Add(lblMissingJournal) attentionBar.Controls.Add(lblRecentRejections) + ' The chips filter the board; the dates themselves live on Deadlines. + Dim lnkDeadlines As New LinkLabel With { + .Text = "View in Deadlines →", + .AutoSize = True, + .UseMnemonic = False, + .LinkBehavior = LinkBehavior.HoverUnderline, + .LinkColor = UiTheme.AccentColor(), + .ActiveLinkColor = UiTheme.AccentSecondaryColor(), + .VisitedLinkColor = UiTheme.AccentColor(), + .Margin = New Padding(UiTheme.Px(4, DeviceDpi), 5, 0, 0), + .AccessibleName = "View in Deadlines", + .AccessibleDescription = "Opens the Deadlines page (Ctrl+4)." + } + AddHandler lnkDeadlines.LinkClicked, Sub(sender, e) NavigateTo(WorkspacePage.Deadlines) + attentionBar.Controls.Add(lnkDeadlines) + body.Controls.Add(attentionBar, 0, 1) ' Shelf tabs with the stage filter and sort beside them. @@ -2174,7 +2190,7 @@ Public Class Form1 NavigateTo(WorkspacePage.Journals) Return True Case Keys.Control Or Keys.D4, Keys.Control Or Keys.NumPad4 - NavigateTo(WorkspacePage.Reminders) + NavigateTo(WorkspacePage.Deadlines) Return True Case Keys.Control Or Keys.D5, Keys.Control Or Keys.NumPad5 NavigateTo(WorkspacePage.ImportExport) diff --git a/ManuscriptPipeline/Forms/AddManuscriptForm.vb b/ManuscriptPipeline/Forms/AddManuscriptForm.vb index 7f023d1..31165d5 100644 --- a/ManuscriptPipeline/Forms/AddManuscriptForm.vb +++ b/ManuscriptPipeline/Forms/AddManuscriptForm.vb @@ -312,6 +312,30 @@ Namespace Forms End Function + ' The date picker fits the longest short date in this culture, plus + ' its drop-down button, at the display's scaling (#76). A fixed width + ' clipped the year at 150%. + Friend Sub SizeDeadlinePicker() + + Dim longest As String = New DateTime(2026, 12, 28).ToString("d", Globalization.CultureInfo.CurrentCulture) + Dim textWidth As Integer = TextRenderer.MeasureText(longest, dtpDeadline.Font).Width + + dtpDeadline.Width = textWidth + + SystemInformation.GetVerticalScrollBarWidthForDpi(DeviceDpi) + + LogicalToDeviceUnits(18) + + End Sub + + Protected Overrides Sub OnLoad(e As EventArgs) + MyBase.OnLoad(e) + SizeDeadlinePicker() + End Sub + + Protected Overrides Sub OnDpiChanged(e As DpiChangedEventArgs) + MyBase.OnDpiChanged(e) + SizeDeadlinePicker() + End Sub + Private Sub OpenTitlePage( sender As Object, e As EventArgs diff --git a/ManuscriptPipeline/Forms/DeadlineDateForm.vb b/ManuscriptPipeline/Forms/DeadlineDateForm.vb new file mode 100644 index 0000000..01b9556 --- /dev/null +++ b/ManuscriptPipeline/Forms/DeadlineDateForm.vb @@ -0,0 +1,160 @@ +Imports System +Imports System.Drawing +Imports System.Globalization +Imports System.Windows.Forms +Imports ManuscriptPipeline.Controls +Imports ManuscriptPipeline.Services + +Namespace Forms + + ' Postpone or set a deadline: a few near dates or any date. The caller + ' says which record the date belongs to. + Friend Class DeadlineDateForm + Inherits Form + + Private ReadOnly _today As DateTime + Private ReadOnly _choices As New List(Of (Button As RadioButton, Days As Integer))() + Private ReadOnly rdoPick As New RadioButton() + Private ReadOnly dtpDate As New DateTimePicker() + Private ReadOnly btnApply As New ActionButton() + Private ReadOnly _verb As String + + Public Sub New(heading As String, context As String, note As String, verb As String, current As DateTime?, today As DateTime) + + _today = today.Date + _verb = verb + + Text = heading + FormBorderStyle = FormBorderStyle.FixedDialog + MaximizeBox = False + MinimizeBox = False + ShowInTaskbar = False + StartPosition = FormStartPosition.CenterParent + AutoSize = True + AutoSizeMode = AutoSizeMode.GrowAndShrink + AutoScaleMode = AutoScaleMode.Dpi + Font = New Font("Segoe UI", 9.0F) + + Dim root As New TableLayoutPanel With { + .AutoSize = True, + .AutoSizeMode = AutoSizeMode.GrowAndShrink, + .ColumnCount = 1, + .Padding = New Padding(18, 16, 18, 14) + } + + Dim lblHeading As New Label With { + .Text = heading, + .AutoSize = True, + .UseMnemonic = False, + .Font = New Font(Font, FontStyle.Bold), + .Margin = New Padding(0, 0, 0, 2) + } + Dim lblContext As New Label With { + .Text = context, + .AutoSize = True, + .UseMnemonic = False, + .MaximumSize = New Size(380, 0), + .ForeColor = SystemColors.GrayText, + .Margin = New Padding(0, 0, 0, 12) + } + root.Controls.Add(lblHeading) + root.Controls.Add(lblContext) + + Dim choices As New FlowLayoutPanel With { + .AutoSize = True, + .AutoSizeMode = AutoSizeMode.GrowAndShrink, + .FlowDirection = FlowDirection.TopDown, + .WrapContents = False, + .Margin = New Padding(0, 0, 0, 8), + .AccessibleName = "New date", + .AccessibleRole = AccessibleRole.Grouping + } + + For Each choice In {(1, "Tomorrow"), (7, "In 1 week"), (14, "In 2 weeks")} + Dim radio As New RadioButton With { + .Text = choice.Item2 & " · " & _today.AddDays(choice.Item1).ToString("ddd, MMM d", CultureInfo.CurrentCulture), + .AutoSize = True, + .UseMnemonic = False, + .Margin = New Padding(0, 0, 0, 4) + } + AddHandler radio.CheckedChanged, AddressOf ChoiceChanged + _choices.Add((radio, choice.Item1)) + choices.Controls.Add(radio) + Next + + Dim pickRow As New FlowLayoutPanel With {.AutoSize = True, .AutoSizeMode = AutoSizeMode.GrowAndShrink, .WrapContents = False, .Margin = New Padding(0)} + rdoPick.Text = "Pick a date" + rdoPick.AutoSize = True + rdoPick.Margin = New Padding(0, 4, 8, 0) + AddHandler rdoPick.CheckedChanged, AddressOf ChoiceChanged + dtpDate.Format = DateTimePickerFormat.Long + dtpDate.Width = 220 + dtpDate.AccessibleName = "Pick a date" + dtpDate.Value = If(current.HasValue AndAlso current.Value.Date > _today, current.Value.Date, _today.AddDays(7)) + AddHandler dtpDate.ValueChanged, Sub(sender, e) + rdoPick.Checked = True + UpdateApply() + End Sub + pickRow.Controls.Add(rdoPick) + pickRow.Controls.Add(dtpDate) + choices.Controls.Add(pickRow) + root.Controls.Add(choices) + + Dim lblNote As New Label With { + .Text = note, + .AutoSize = True, + .UseMnemonic = False, + .MaximumSize = New Size(380, 0), + .ForeColor = SystemColors.GrayText, + .Margin = New Padding(0, 4, 0, 12) + } + root.Controls.Add(lblNote) + + Dim buttons As New FlowLayoutPanel With { + .AutoSize = True, + .AutoSizeMode = AutoSizeMode.GrowAndShrink, + .FlowDirection = FlowDirection.RightToLeft, + .WrapContents = False, + .Dock = DockStyle.Fill, + .Margin = New Padding(0) + } + btnApply.Role = ActionButtonRole.Primary + btnApply.Height = 34 + btnApply.DialogResult = DialogResult.OK + Dim btnCancel As New ActionButton With {.Text = "Cancel", .Height = 34, .Width = 90, .DialogResult = DialogResult.Cancel, .Margin = New Padding(0, 0, 8, 0)} + buttons.Controls.Add(btnApply) + buttons.Controls.Add(btnCancel) + root.Controls.Add(buttons) + + Controls.Add(root) + AcceptButton = btnApply + CancelButton = btnCancel + + _choices(If(current.HasValue AndAlso current.Value.Date >= _today, 1, 0)).Button.Checked = True + UiPolish.ApplyDialog(Me) + + End Sub + + Public ReadOnly Property SelectedDate As DateTime + Get + If rdoPick.Checked Then Return dtpDate.Value.Date + For Each choice In _choices + If choice.Button.Checked Then Return _today.AddDays(choice.Days) + Next + Return _today.AddDays(7) + End Get + End Property + + Private Sub ChoiceChanged(sender As Object, e As EventArgs) + UpdateApply() + End Sub + + ' The button says exactly what will happen: "Postpone to Oct 4". + Private Sub UpdateApply() + btnApply.Text = _verb & " " & SelectedDate.ToString("MMM d", CultureInfo.CurrentCulture) + btnApply.Width = TextRenderer.MeasureText(btnApply.Text, Font).Width + 36 + End Sub + + End Class + +End Namespace diff --git a/ManuscriptPipeline/Forms/EditManuscriptForm.vb b/ManuscriptPipeline/Forms/EditManuscriptForm.vb index 3ce8066..8bb99a9 100644 --- a/ManuscriptPipeline/Forms/EditManuscriptForm.vb +++ b/ManuscriptPipeline/Forms/EditManuscriptForm.vb @@ -239,6 +239,36 @@ Namespace Forms End Sub + ' Opens the Submissions tab on one submission, for a deadline opened + ' from the Deadlines page; a revision with comments lands on its + ' reviewer responses. + Friend Sub ShowSubmission( + submissionId As Guid, + showResponses As Boolean + ) + + ShowSection(_submissionsSection) + SelectSubmissionById(submissionId) + + If showResponses Then + _submissionDetail?.ShowReviewerResponses() + End If + + End Sub + + + Friend Sub ShowReadinessAndPackets() + + For Each tab As ShelfTabButton In _sectionTabs + If tab.Text = "Readiness & Packets" Then + ShowSection(DirectCast(tab.Tag, Control)) + Return + End If + Next + + End Sub + + Friend Sub ConfirmDeleteFromPage() RequestDelete( diff --git a/ManuscriptPipeline/Forms/FillBlanksForm.vb b/ManuscriptPipeline/Forms/FillBlanksForm.vb new file mode 100644 index 0000000..de0fac7 --- /dev/null +++ b/ManuscriptPipeline/Forms/FillBlanksForm.vb @@ -0,0 +1,307 @@ +Imports System +Imports System.Collections.Generic +Imports System.Drawing +Imports System.Globalization +Imports System.Linq +Imports System.Text.Json +Imports System.Threading +Imports System.Threading.Tasks +Imports System.Windows.Forms +Imports ManuscriptPipeline.Controls +Imports ManuscriptPipeline.Models +Imports ManuscriptPipeline.Services + +Namespace Forms + + ' Fill Blanks from Crossref (#61): for manuscripts with a DOI, look up + ' the Crossref record and fill only empty fields, after a preview of + ' every change. + Friend Class FillBlanksForm + Inherits Form + + Private ReadOnly _library As List(Of Manuscript) + Private ReadOnly _eligible As List(Of Manuscript) + Private ReadOnly _source As IPublicationSource + Private ReadOnly _save As Func(Of Boolean) + + Private ReadOnly lstManuscripts As New CheckedListBox() + Private ReadOnly lstChanges As New ListView() + Private ReadOnly lblIntro As New Label() + Private ReadOnly lblProgress As New Label() + Private ReadOnly body As New Panel() + Private ReadOnly btnPrimary As New ActionButton() + Private ReadOnly btnCancel As New ActionButton() + + Private _plan As New List(Of MetadataFill)() + Private _works As New Dictionary(Of Manuscript, CrossrefMetadataSuggestion)() + Private _cancellation As CancellationTokenSource = Nothing + Private _stage As Integer = 0 + + Friend Pause As TimeSpan = TimeSpan.FromMilliseconds(250) + + + Friend Property FilledCount As Integer + + Public Sub New(library As List(Of Manuscript), source As IPublicationSource, save As Func(Of Boolean)) + + _library = library + _source = source + _save = save + _eligible = library.Where(Function(item) DoiNormalizer.IsValid(DoiNormalizer.Normalize(If(item.Metadata?.Doi, String.Empty)))). + OrderBy(Function(item) item.Title, StringComparer.CurrentCultureIgnoreCase).ToList() + + Text = "Fill Blanks from Crossref" + StartPosition = FormStartPosition.CenterParent + ShowInTaskbar = False + MinimizeBox = False + AutoScaleMode = AutoScaleMode.Dpi + Font = New Font("Segoe UI", 9.0F) + ClientSize = New Size(760, 540) + MinimumSize = New Size(560, 420) + + Dim root As New TableLayoutPanel With {.Dock = DockStyle.Fill, .ColumnCount = 1, .RowCount = 3, .Padding = New Padding(18, 16, 18, 14)} + root.ColumnStyles.Add(New ColumnStyle(SizeType.Percent, 100)) + root.RowStyles.Add(New RowStyle(SizeType.AutoSize)) + root.RowStyles.Add(New RowStyle(SizeType.Percent, 100)) + root.RowStyles.Add(New RowStyle(SizeType.AutoSize)) + + lblIntro.Text = "For manuscripts with a DOI, PaperRoute reads the Crossref record and fills fields that are empty, " & + "such as the journal, date, volume, issue, pages, and abstract. You see every change first. A field that has a value is never changed." + lblIntro.AutoSize = True + lblIntro.MaximumSize = New Size(720, 0) + lblIntro.UseMnemonic = False + lblIntro.Margin = New Padding(0, 0, 0, 12) + root.Controls.Add(lblIntro, 0, 0) + + body.Dock = DockStyle.Fill + body.Margin = New Padding(0) + root.Controls.Add(body, 0, 1) + + Dim buttons As New FlowLayoutPanel With {.AutoSize = True, .AutoSizeMode = AutoSizeMode.GrowAndShrink, .FlowDirection = FlowDirection.RightToLeft, .WrapContents = False, .Dock = DockStyle.Fill, .Margin = New Padding(0, 12, 0, 0)} + btnPrimary.Role = ActionButtonRole.Primary + btnPrimary.Height = 34 + AddHandler btnPrimary.Click, AddressOf PrimaryClicked + btnCancel.Text = "Cancel" + btnCancel.Height = 34 + btnCancel.Width = 90 + btnCancel.Margin = New Padding(0, 0, 8, 0) + AddHandler btnCancel.Click, AddressOf CancelClicked + buttons.Controls.Add(btnPrimary) + buttons.Controls.Add(btnCancel) + root.Controls.Add(buttons, 0, 2) + + Controls.Add(root) + AcceptButton = btnPrimary + + lstManuscripts.Dock = DockStyle.Fill + lstManuscripts.CheckOnClick = True + lstManuscripts.IntegralHeight = False + lstManuscripts.AccessibleName = "Manuscripts with a DOI" + For Each manuscript As Manuscript In _eligible + Dim index As Integer = lstManuscripts.Items.Add(ReminderService.SafeManuscriptTitle(manuscript) & " · DOI " & DoiNormalizer.Normalize(manuscript.Metadata.Doi)) + lstManuscripts.SetItemChecked(index, HasBlanks(manuscript)) + Next + AddHandler lstManuscripts.ItemCheck, Sub(sender, e) If IsHandleCreated Then BeginInvoke(New Action(AddressOf UpdateButtons)) + body.Controls.Add(lstManuscripts) + + If _eligible.Count = 0 Then + lblIntro.Text &= Environment.NewLine & Environment.NewLine & "No manuscript has a DOI yet. Add one on a manuscript's Overview tab, or use Check for Publications." + lstManuscripts.Enabled = False + End If + + UpdateButtons() + UiPolish.ApplyDialog(Me) + + End Sub + + + Private Shared Function HasBlanks(manuscript As Manuscript) As Boolean + Dim metadata As ManuscriptMetadata = manuscript.Metadata + Return String.IsNullOrWhiteSpace(metadata.PublicationJournal) OrElse Not metadata.PublishedDate.HasValue OrElse + String.IsNullOrWhiteSpace(metadata.Volume) OrElse String.IsNullOrWhiteSpace(metadata.Pages) OrElse + String.IsNullOrWhiteSpace(metadata.AbstractText) + End Function + + + Private Sub UpdateButtons() + Select Case _stage + Case 0 + Dim count As Integer = lstManuscripts.CheckedIndices.Count + btnPrimary.Text = If(count = 1, "Look Up 1 Manuscript", "Look Up " & count.ToString(CultureInfo.CurrentCulture) & " Manuscripts") + btnPrimary.Enabled = count > 0 + Case 2 + Dim count As Integer = lstChanges.CheckedItems.Count + btnPrimary.Text = If(count = 1, "Fill 1 Field", "Fill " & count.ToString(CultureInfo.CurrentCulture) & " Fields") + btnPrimary.Enabled = count > 0 + End Select + btnPrimary.Width = TextRenderer.MeasureText(btnPrimary.Text, Font).Width + 36 + End Sub + + + Private Async Sub PrimaryClicked(sender As Object, e As EventArgs) + Select Case _stage + Case 0 : Await LookUpAsync() + Case 2 : ApplyChecked() + End Select + End Sub + + + Private Sub CancelClicked(sender As Object, e As EventArgs) + If _cancellation IsNot Nothing Then + _cancellation.Cancel() + btnCancel.Enabled = False + Return + End If + DialogResult = DialogResult.Cancel + Close() + End Sub + + + Friend Async Function LookUpAsync() As Task + + Dim chosen As List(Of Manuscript) = lstManuscripts.CheckedIndices.Cast(Of Integer)().Select(Function(index) _eligible(index)).ToList() + If chosen.Count = 0 Then Return + + _stage = 1 + body.Controls.Remove(lstManuscripts) + lblProgress.AutoSize = True + lblProgress.UseMnemonic = False + lblProgress.Margin = New Padding(0, 24, 0, 0) + lblProgress.Dock = DockStyle.Top + body.Controls.Add(lblProgress) + btnPrimary.Visible = False + btnCancel.Text = "Stop" + + Dim failures As New List(Of String)() + Dim stopped As String = String.Empty + _cancellation = New CancellationTokenSource() + + Try + For index As Integer = 0 To chosen.Count - 1 + Dim manuscript As Manuscript = chosen(index) + lblProgress.Text = "Looking up " & (index + 1).ToString(CultureInfo.CurrentCulture) & " of " & chosen.Count.ToString(CultureInfo.CurrentCulture) & ": " & manuscript.Title + Try + Dim work As CrossrefMetadataSuggestion = Await _source.LookupDoiAsync(DoiNormalizer.Normalize(manuscript.Metadata.Doi), _cancellation.Token) + If work Is Nothing Then + failures.Add(ReminderService.SafeManuscriptTitle(manuscript) & ": Crossref has no record for its DOI.") + Else + _works(manuscript) = work + _plan.AddRange(PublicationMatchService.PlanFill(manuscript, work)) + End If + If Pause > TimeSpan.Zero Then Await Task.Delay(Pause, _cancellation.Token) + Catch ex As CrossrefRateLimitException + stopped = ex.Message + Exit For + Catch ex As OperationCanceledException When _cancellation.IsCancellationRequested + stopped = "The lookup was stopped." + Exit For + Catch ex As Exception When TypeOf ex Is System.Net.Http.HttpRequestException OrElse TypeOf ex Is InvalidOperationException OrElse + TypeOf ex Is OperationCanceledException OrElse TypeOf ex Is JsonException + failures.Add(ReminderService.SafeManuscriptTitle(manuscript) & ": " & ex.Message) + End Try + Next + Finally + _cancellation.Dispose() + _cancellation = Nothing + End Try + + ShowPreview(failures, stopped) + + End Function + + + Private Sub ShowPreview(failures As List(Of String), stopped As String) + + _stage = 2 + body.Controls.Remove(lblProgress) + btnCancel.Text = "Cancel" + btnCancel.Enabled = True + btnPrimary.Visible = True + + lblIntro.Text = If(_plan.Count = 0, + "Every field Crossref could fill already has a value. Nothing to change.", + "Uncheck anything you do not want. Only empty fields are listed; nothing else changes.") + If failures.Count > 0 Then lblIntro.Text &= Environment.NewLine & "Not looked up:" & Environment.NewLine & String.Join(Environment.NewLine, failures.Select(Function(line) "• " & line)) + If stopped.Length > 0 Then lblIntro.Text &= Environment.NewLine & "Stopped early: " & stopped + + lstChanges.View = View.Details + lstChanges.CheckBoxes = True + lstChanges.FullRowSelect = True + lstChanges.HeaderStyle = ColumnHeaderStyle.Nonclickable + lstChanges.Dock = DockStyle.Fill + lstChanges.AccessibleName = "Changes to make" + lstChanges.Columns.Add("Manuscript", LogicalToDeviceUnits(250)) + lstChanges.Columns.Add("Field", LogicalToDeviceUnits(130)) + lstChanges.Columns.Add("New value", LogicalToDeviceUnits(330)) + For Each fill As MetadataFill In _plan + Dim value As String = If(fill.Value.Length > 160, fill.Value.Substring(0, 157) & "...", fill.Value) + Dim row As New ListViewItem({ReminderService.SafeManuscriptTitle(fill.Manuscript), fill.Field, value}) With {.Checked = True, .Tag = fill} + lstChanges.Items.Add(row) + Next + AddHandler lstChanges.ItemChecked, Sub(sender, e) UpdateButtons() + body.Controls.Add(lstChanges) + + If _plan.Count = 0 Then + btnPrimary.Text = "Close" + btnPrimary.Width = 90 + btnPrimary.Enabled = True + RemoveHandler btnPrimary.Click, AddressOf PrimaryClicked + AddHandler btnPrimary.Click, Sub(sender, e) Close() + btnCancel.Visible = False + Else + UpdateButtons() + End If + + UiPolish.ApplyDialog(Me) + + End Sub + + + Friend ReadOnly Property PlannedChanges As List(Of MetadataFill) + Get + Return _plan + End Get + End Property + + + ' All at once: if the save fails, every manuscript goes back. + Friend Sub ApplyChecked() + + Dim fills As List(Of MetadataFill) = lstChanges.CheckedItems.Cast(Of ListViewItem)().Select(Function(row) DirectCast(row.Tag, MetadataFill)).ToList() + If fills.Count = 0 Then Return + + Dim touched As List(Of Manuscript) = fills.Select(Function(fill) fill.Manuscript).Distinct().ToList() + Dim snapshots As Dictionary(Of Manuscript, String) = touched.ToDictionary(Function(item) item, Function(item) JsonSerializer.Serialize(item)) + + For Each manuscript As Manuscript In touched + PublicationMatchService.ApplyFill(manuscript, fills.Where(Function(fill) fill.Manuscript Is manuscript)) + CrossrefApplyService.RecordProvenance(manuscript.Metadata, _works(manuscript)) + Next + + If Not _save() Then + For Each manuscript As Manuscript In touched + Dim index As Integer = _library.IndexOf(manuscript) + If index >= 0 Then _library(index) = JsonSerializer.Deserialize(Of Manuscript)(snapshots(manuscript)) + Next + Return + End If + + FilledCount = fills.Count + DialogResult = DialogResult.OK + Close() + + End Sub + + + Protected Overrides Sub OnFormClosing(e As FormClosingEventArgs) + If _cancellation IsNot Nothing Then + _cancellation.Cancel() + e.Cancel = True + Return + End If + MyBase.OnFormClosing(e) + End Sub + + End Class + +End Namespace diff --git a/ManuscriptPipeline/Forms/MarkPublishedForm.vb b/ManuscriptPipeline/Forms/MarkPublishedForm.vb new file mode 100644 index 0000000..b8734eb --- /dev/null +++ b/ManuscriptPipeline/Forms/MarkPublishedForm.vb @@ -0,0 +1,183 @@ +Imports System +Imports System.Collections.Generic +Imports System.Diagnostics +Imports System.Drawing +Imports System.Globalization +Imports System.Linq +Imports System.Text.Json +Imports System.Windows.Forms +Imports ManuscriptPipeline.Controls +Imports ManuscriptPipeline.Models +Imports ManuscriptPipeline.Services + +Namespace Forms + + ' Confirms Mark Published (#61) by listing exactly what will change. + Friend Class MarkPublishedForm + Inherits Form + + Public Sub New(manuscript As Manuscript, match As PublicationMatch, today As DateTime) + + Text = "Mark as Published" + FormBorderStyle = FormBorderStyle.FixedDialog + MaximizeBox = False + MinimizeBox = False + ShowInTaskbar = False + StartPosition = FormStartPosition.CenterParent + AutoSize = True + AutoSizeMode = AutoSizeMode.GrowAndShrink + AutoScaleMode = AutoScaleMode.Dpi + Font = New Font("Segoe UI", 9.0F) + + Dim root As New TableLayoutPanel With { + .AutoSize = True, + .AutoSizeMode = AutoSizeMode.GrowAndShrink, + .ColumnCount = 1, + .Padding = New Padding(18, 16, 18, 14) + } + + root.Controls.Add(New Label With { + .Text = "Mark """ & ReminderService.SafeManuscriptTitle(manuscript) & """ as published?", + .AutoSize = True, + .MaximumSize = New Size(460, 0), + .UseMnemonic = False, + .Font = New Font(Font, FontStyle.Bold), + .Margin = New Padding(0, 0, 0, 10) + }) + + root.Controls.Add(New Label With { + .Text = PublicationActions.Describe(match), + .AutoSize = True, + .MaximumSize = New Size(460, 0), + .UseMnemonic = False, + .Margin = New Padding(0, 0, 0, 12) + }) + + root.Controls.Add(New Label With { + .Text = "PaperRoute will:", + .AutoSize = True, + .UseMnemonic = False, + .Margin = New Padding(0, 0, 0, 4) + }) + + For Each line As String In PublicationMatchService.DescribeMarkPublished(manuscript, match, today) + root.Controls.Add(New Label With { + .Text = "• " & line, + .AutoSize = True, + .MaximumSize = New Size(460, 0), + .UseMnemonic = False, + .Margin = New Padding(8, 0, 0, 4) + }) + Next + + root.Controls.Add(New Label With { + .Text = "Recorded submissions and decisions are left as they are.", + .AutoSize = True, + .MaximumSize = New Size(460, 0), + .UseMnemonic = False, + .ForeColor = SystemColors.GrayText, + .Margin = New Padding(0, 8, 0, 14) + }) + + Dim buttons As New FlowLayoutPanel With { + .AutoSize = True, + .AutoSizeMode = AutoSizeMode.GrowAndShrink, + .FlowDirection = FlowDirection.RightToLeft, + .WrapContents = False, + .Dock = DockStyle.Fill, + .Margin = New Padding(0) + } + Dim btnMark As New ActionButton With { + .Text = "Mark Published", + .Role = ActionButtonRole.Primary, + .Height = 34, + .Width = 140, + .DialogResult = DialogResult.OK + } + Dim btnCancel As New ActionButton With {.Text = "Cancel", .Height = 34, .Width = 90, .DialogResult = DialogResult.Cancel, .Margin = New Padding(0, 0, 8, 0)} + buttons.Controls.Add(btnMark) + buttons.Controls.Add(btnCancel) + root.Controls.Add(buttons) + + Controls.Add(root) + AcceptButton = btnMark + CancelButton = btnCancel + UiPolish.ApplyDialog(Me) + + End Sub + + End Class + + + ' Shared by the Publication Check window and the Deadlines page. + Friend NotInheritable Class PublicationActions + + Private Sub New() + End Sub + + + ' "Title" · Journal · date · DOI + Public Shared Function Describe(match As PublicationMatch) As String + Dim parts As New List(Of String)() + If Not String.IsNullOrWhiteSpace(match.Title) Then parts.Add("""" & match.Title.Trim() & """") + If Not String.IsNullOrWhiteSpace(match.Journal) Then parts.Add(match.Journal.Trim()) + If match.PublishedDate.HasValue Then parts.Add(match.PublishedDate.Value.ToString("MMM d, yyyy", CultureInfo.CurrentCulture)) + If Not String.IsNullOrWhiteSpace(match.Doi) Then parts.Add("DOI " & match.Doi) + Return String.Join(" · ", parts) + End Function + + + Public Shared Function SourceText(match As PublicationMatch) As String + Select Case match.Source + Case PublicationMatchSource.Doi : Return "The manuscript's DOI now resolves to this article." + Case PublicationMatchSource.Preprint : Return "The manuscript's preprint links to this published version." + Case PublicationMatchSource.Orcid : Return "The ORCID record lists a work with the same title." + Case Else : Return "Crossref lists a work with the same title." + End Select + End Function + + + ' The DOI's landing page, or the work's own web address. + Public Shared Function MatchUri(match As PublicationMatch) As Uri + Dim target As Uri = Nothing + Dim address As String = If(String.IsNullOrWhiteSpace(match.Doi), match.Url, "https://doi.org/" & match.Doi) + If Not Uri.TryCreate(address, UriKind.Absolute, target) Then Return Nothing + If target.Scheme <> Uri.UriSchemeHttps AndAlso target.Scheme <> Uri.UriSchemeHttp Then Return Nothing + Return target + End Function + + + Public Shared Sub OpenMatch(owner As IWin32Window, match As PublicationMatch) + Dim target As Uri = MatchUri(match) + If target Is Nothing Then + MessageBox.Show(owner, "This match has no DOI or web address to open.", "Review Match", MessageBoxButtons.OK, MessageBoxIcon.Information) + Return + End If + Try + Process.Start(New ProcessStartInfo(target.AbsoluteUri) With {.UseShellExecute = True}) + Catch ex As Exception + MessageBox.Show(owner, "PaperRoute could not open the web page." & Environment.NewLine & ex.Message, + "Review Match", MessageBoxButtons.OK, MessageBoxIcon.Information) + End Try + End Sub + + + ' Makes a change and saves. If saving fails, the manuscript goes back + ' to exactly what it was. + Public Shared Function Commit(library As IList(Of Manuscript), manuscript As Manuscript, change As Action, save As Func(Of Boolean)) As Boolean + + Dim index As Integer = library.IndexOf(manuscript) + Dim snapshot As String = JsonSerializer.Serialize(manuscript) + + change() + + If save() Then Return True + + If index >= 0 Then library(index) = JsonSerializer.Deserialize(Of Manuscript)(snapshot) + Return False + + End Function + + End Class + +End Namespace diff --git a/ManuscriptPipeline/Forms/PublicationCheckForm.vb b/ManuscriptPipeline/Forms/PublicationCheckForm.vb new file mode 100644 index 0000000..d071779 --- /dev/null +++ b/ManuscriptPipeline/Forms/PublicationCheckForm.vb @@ -0,0 +1,535 @@ +Imports System +Imports System.Collections.Generic +Imports System.Drawing +Imports System.Globalization +Imports System.Linq +Imports System.Threading +Imports System.Threading.Tasks +Imports System.Windows.Forms +Imports ManuscriptPipeline.Controls +Imports ManuscriptPipeline.Models +Imports ManuscriptPipeline.Services + +Namespace Forms + + ' Check for Publications (#61): choose manuscripts, ask Crossref (and + ' optionally an ORCID record) once, then review each possible match. + ' Nothing changes a manuscript except Mark Published. + Friend Class PublicationCheckForm + Inherits Form + + Private ReadOnly _library As List(Of Manuscript) + Private ReadOnly _eligible As List(Of Manuscript) + Private ReadOnly _source As IPublicationSource + Private ReadOnly _save As Func(Of Boolean) + Private ReadOnly _today As DateTime + + Private ReadOnly lstManuscripts As New CheckedListBox() + Private ReadOnly lblSelected As New Label() + Private ReadOnly chkOrcid As New CheckBox() + Private ReadOnly txtOrcid As New TextBox() + Private ReadOnly lblProgress As New Label() + Private ReadOnly progressBar As New ProgressBar() + Private ReadOnly btnPrimary As New ActionButton() + Private ReadOnly btnCancel As New ActionButton() + Private ReadOnly body As New Panel() + Private ReadOnly lblIntro As New Label() + + Private _cancellation As CancellationTokenSource = Nothing + + ' Tests answer the Mark Published confirmation and skip the pause. + Friend ConfirmMarkPublished As Func(Of Manuscript, PublicationMatch, Boolean) = Nothing + Friend Pause As TimeSpan = TimeSpan.FromMilliseconds(250) + + ' Matches found or changed, for the caller to refresh its pages. + + Friend Property Changed As Boolean + + Public Sub New( + library As List(Of Manuscript), + checkIds As IEnumerable(Of Guid), + defaultOrcid As String, + source As IPublicationSource, + save As Func(Of Boolean), + today As DateTime + ) + + _library = library + _source = source + _save = save + _today = today.Date + + Dim requested As New HashSet(Of Guid)(If(checkIds, Enumerable.Empty(Of Guid)())) + _eligible = library.Where(Function(item) PublicationMatchService.IsEligible(item)). + OrderByDescending(Function(item) If(requested.Count > 0, requested.Contains(item.Id), PublicationMatchService.IsSuggested(item))). + ThenBy(Function(item) item.Title, StringComparer.CurrentCultureIgnoreCase).ToList() + + Text = "Check for Publications" + StartPosition = FormStartPosition.CenterParent + ShowInTaskbar = False + MinimizeBox = False + AutoScaleMode = AutoScaleMode.Dpi + Font = New Font("Segoe UI", 9.0F) + ClientSize = New Size(720, 560) + MinimumSize = New Size(560, 440) + + Dim root As New TableLayoutPanel With { + .Dock = DockStyle.Fill, + .ColumnCount = 1, + .RowCount = 3, + .Padding = New Padding(18, 16, 18, 14) + } + root.ColumnStyles.Add(New ColumnStyle(SizeType.Percent, 100)) + root.RowStyles.Add(New RowStyle(SizeType.AutoSize)) + root.RowStyles.Add(New RowStyle(SizeType.Percent, 100)) + root.RowStyles.Add(New RowStyle(SizeType.AutoSize)) + + lblIntro.Text = "PaperRoute asks Crossref whether the manuscripts you check have been published: by their DOI, " & + "a preprint's link to its published version, or the same title. Only now, only for these manuscripts. " & + "Nothing changes until you choose Mark Published." + lblIntro.AutoSize = True + lblIntro.MaximumSize = New Size(680, 0) + lblIntro.UseMnemonic = False + lblIntro.Margin = New Padding(0, 0, 0, 12) + root.Controls.Add(lblIntro, 0, 0) + + body.Dock = DockStyle.Fill + body.Margin = New Padding(0) + root.Controls.Add(body, 0, 1) + + Dim buttons As New FlowLayoutPanel With { + .AutoSize = True, + .AutoSizeMode = AutoSizeMode.GrowAndShrink, + .FlowDirection = FlowDirection.RightToLeft, + .WrapContents = False, + .Dock = DockStyle.Fill, + .Margin = New Padding(0, 12, 0, 0) + } + btnPrimary.Role = ActionButtonRole.Primary + btnPrimary.Height = 34 + AddHandler btnPrimary.Click, AddressOf PrimaryClicked + btnCancel.Text = "Cancel" + btnCancel.Height = 34 + btnCancel.Width = 90 + btnCancel.Margin = New Padding(0, 0, 8, 0) + AddHandler btnCancel.Click, AddressOf CancelClicked + buttons.Controls.Add(btnPrimary) + buttons.Controls.Add(btnCancel) + root.Controls.Add(buttons, 0, 2) + + Controls.Add(root) + AcceptButton = btnPrimary + + ShowChoices(requested, defaultOrcid) + UiPolish.ApplyDialog(Me) + + End Sub + + + ' --------------------------------------------------------------- + ' Choose + ' --------------------------------------------------------------- + + Private Sub ShowChoices(requested As HashSet(Of Guid), defaultOrcid As String) + + Dim layout As New TableLayoutPanel With {.Dock = DockStyle.Fill, .ColumnCount = 1, .RowCount = 4, .Margin = New Padding(0)} + layout.ColumnStyles.Add(New ColumnStyle(SizeType.Percent, 100)) + layout.RowStyles.Add(New RowStyle(SizeType.Percent, 100)) + layout.RowStyles.Add(New RowStyle(SizeType.AutoSize)) + layout.RowStyles.Add(New RowStyle(SizeType.AutoSize)) + layout.RowStyles.Add(New RowStyle(SizeType.AutoSize)) + + lstManuscripts.Dock = DockStyle.Fill + lstManuscripts.CheckOnClick = True + lstManuscripts.IntegralHeight = False + lstManuscripts.HorizontalScrollbar = True + lstManuscripts.AccessibleName = "Manuscripts to check" + For Each manuscript As Manuscript In _eligible + Dim index As Integer = lstManuscripts.Items.Add(Describe(manuscript)) + lstManuscripts.SetItemChecked(index, If(requested.Count > 0, requested.Contains(manuscript.Id), PublicationMatchService.IsSuggested(manuscript))) + Next + AddHandler lstManuscripts.ItemCheck, Sub(sender, e) BeginInvokeIfReady(AddressOf UpdateSelection) + layout.Controls.Add(lstManuscripts, 0, 0) + + Dim selectRow As New FlowLayoutPanel With {.AutoSize = True, .AutoSizeMode = AutoSizeMode.GrowAndShrink, .WrapContents = False, .Margin = New Padding(0, 6, 0, 6)} + For Each entry In {("Select all", True), ("Select none", False)} + Dim value As Boolean = entry.Item2 + Dim link As New LinkLabel With {.Text = entry.Item1, .AutoSize = True, .Margin = New Padding(0, 0, 14, 0), .UseMnemonic = False} + AddHandler link.LinkClicked, + Sub(sender, e) + For index As Integer = 0 To lstManuscripts.Items.Count - 1 + lstManuscripts.SetItemChecked(index, value) + Next + UpdateSelection() + End Sub + selectRow.Controls.Add(link) + Next + lblSelected.AutoSize = True + lblSelected.UseMnemonic = False + lblSelected.ForeColor = SystemColors.GrayText + selectRow.Controls.Add(lblSelected) + layout.Controls.Add(selectRow, 0, 1) + + Dim orcidRow As New FlowLayoutPanel With {.AutoSize = True, .AutoSizeMode = AutoSizeMode.GrowAndShrink, .WrapContents = False, .Margin = New Padding(0, 0, 0, 4)} + chkOrcid.Text = "Also look in the ORCID record" + chkOrcid.AutoSize = True + chkOrcid.Margin = New Padding(0, 4, 8, 0) + chkOrcid.Checked = Not String.IsNullOrWhiteSpace(defaultOrcid) + txtOrcid.Text = If(defaultOrcid, String.Empty) + txtOrcid.Width = 190 + txtOrcid.PlaceholderText = "0000-0000-0000-0000" + txtOrcid.AccessibleName = "ORCID iD" + AddHandler txtOrcid.TextChanged, Sub(sender, e) chkOrcid.Checked = txtOrcid.TextLength > 0 + orcidRow.Controls.Add(chkOrcid) + orcidRow.Controls.Add(txtOrcid) + layout.Controls.Add(orcidRow, 0, 2) + + layout.Controls.Add(New Label With { + .Text = "Crossref receives the titles and DOIs of the checked manuscripts; ORCID is only read. Nothing else leaves this computer.", + .AutoSize = True, + .MaximumSize = New Size(680, 0), + .UseMnemonic = False, + .ForeColor = SystemColors.GrayText, + .Margin = New Padding(0, 2, 0, 0) + }, 0, 3) + + body.Controls.Add(layout) + + If _eligible.Count = 0 Then + lstManuscripts.Enabled = False + lblSelected.Text = "Every manuscript is already on the Published shelf." + End If + + UpdateSelection() + + End Sub + + + Private Shared Function Describe(manuscript As Manuscript) As String + Dim parts As New List(Of String) From {ReminderService.SafeManuscriptTitle(manuscript), StageText(manuscript)} + If Not String.IsNullOrWhiteSpace(manuscript.TargetJournal) Then parts.Add(manuscript.TargetJournal.Trim()) + Return String.Join(" · ", parts) + End Function + + + Private Shared Function StageText(manuscript As Manuscript) As String + If manuscript.Location = ManuscriptLocation.FileDrawer Then Return "File Drawer" + Select Case manuscript.CurrentStage + Case PaperStage.UnderReview : Return "Under review" + Case PaperStage.InPress : Return "In press" + Case Else : Return manuscript.CurrentStage.ToString() + End Select + End Function + + + Private Sub BeginInvokeIfReady(action As Action) + If IsHandleCreated Then BeginInvoke(action) Else action() + End Sub + + + Private Sub UpdateSelection() + Dim count As Integer = lstManuscripts.CheckedIndices.Count + If _eligible.Count > 0 Then + lblSelected.Text = count.ToString(CultureInfo.CurrentCulture) & " of " & _eligible.Count.ToString(CultureInfo.CurrentCulture) & " selected" + End If + btnPrimary.Text = If(count = 1, "Check 1 Manuscript", "Check " & count.ToString(CultureInfo.CurrentCulture) & " Manuscripts") + btnPrimary.Width = TextRenderer.MeasureText(btnPrimary.Text, Font).Width + 36 + btnPrimary.Enabled = count > 0 + End Sub + + + Friend ReadOnly Property SelectedManuscripts As List(Of Manuscript) + Get + Return lstManuscripts.CheckedIndices.Cast(Of Integer)().Select(Function(index) _eligible(index)).ToList() + End Get + End Property + + + ' --------------------------------------------------------------- + ' Check + ' --------------------------------------------------------------- + + Private Async Sub PrimaryClicked(sender As Object, e As EventArgs) + If _cancellation IsNot Nothing Then Return + If btnPrimary.Text = "Close" Then + DialogResult = DialogResult.OK + Close() + Return + End If + Await CheckAsync() + End Sub + + + Private Sub CancelClicked(sender As Object, e As EventArgs) + If _cancellation IsNot Nothing Then + _cancellation.Cancel() + btnCancel.Enabled = False + Return + End If + DialogResult = DialogResult.Cancel + Close() + End Sub + + + Friend Async Function CheckAsync() As Task + + Dim chosen As List(Of Manuscript) = SelectedManuscripts + If chosen.Count = 0 Then Return + + Dim orcid As String = String.Empty + If chkOrcid.Checked AndAlso txtOrcid.TextLength > 0 Then + Try + orcid = OrcidIdentifierService.NormalizeAndValidate(txtOrcid.Text) + Catch ex As ArgumentException + MessageBox.Show(Me, ex.Message, "ORCID iD", MessageBoxButtons.OK, MessageBoxIcon.Information) + txtOrcid.Focus() + Return + End Try + End If + + ShowProgress(chosen.Count) + _cancellation = New CancellationTokenSource() + + Dim result As PublicationCheckResult + Try + result = Await PublicationCheckService.CheckAsync( + chosen, orcid, _source, + New Progress(Of PublicationCheckProgress)( + Sub(update) + lblProgress.Text = "Checking " & update.Index.ToString(CultureInfo.CurrentCulture) & " of " & + update.Total.ToString(CultureInfo.CurrentCulture) & ": " & update.ManuscriptTitle + progressBar.Value = Math.Min(progressBar.Maximum, update.Index - 1) + End Sub), + Pause, _cancellation.Token) + Finally + _cancellation.Dispose() + _cancellation = Nothing + End Try + + RecordMatches(result) + ShowResults(result) + + End Function + + + Private Sub ShowProgress(total As Integer) + + ClearBody() + Dim layout As New TableLayoutPanel With {.Dock = DockStyle.Top, .AutoSize = True, .ColumnCount = 1, .Margin = New Padding(0)} + layout.ColumnStyles.Add(New ColumnStyle(SizeType.Percent, 100)) + lblProgress.Text = "Starting..." + lblProgress.AutoSize = True + lblProgress.MaximumSize = New Size(680, 0) + lblProgress.UseMnemonic = False + lblProgress.Margin = New Padding(0, 24, 0, 8) + progressBar.Maximum = Math.Max(1, total) + progressBar.Value = 0 + progressBar.Dock = DockStyle.Top + progressBar.Height = 12 + layout.Controls.Add(lblProgress) + layout.Controls.Add(progressBar) + body.Controls.Add(layout) + + btnPrimary.Visible = False + btnCancel.Text = "Stop" + + End Sub + + + ' New matches are kept at once, so closing the window keeps them for + ' later review on the Deadlines page. + Private Sub RecordMatches(result As PublicationCheckResult) + + If result.Matches.Count = 0 Then Return + + For Each found In result.Matches + found.Manuscript.PublicationMatches.Add(found.Match) + Next + + If Not _save() Then + For Each found In result.Matches + found.Manuscript.PublicationMatches.Remove(found.Match) + Next + Return + End If + + Changed = True + + End Sub + + + ' --------------------------------------------------------------- + ' Review + ' --------------------------------------------------------------- + + Private Sub ShowResults(result As PublicationCheckResult) + + ClearBody() + btnPrimary.Visible = True + btnPrimary.Text = "Close" + btnPrimary.Width = 90 + btnPrimary.Enabled = True + btnCancel.Visible = False + lblIntro.Text = Summary(result) + + Dim scroller As New Panel With {.Dock = DockStyle.Fill, .AutoScroll = True, .Margin = New Padding(0)} + Dim list As New TableLayoutPanel With { + .Dock = DockStyle.Top, + .AutoSize = True, + .AutoSizeMode = AutoSizeMode.GrowAndShrink, + .ColumnCount = 1, + .Margin = New Padding(0), + .Padding = New Padding(0, 0, 8, 0), + .AccessibleName = "Possible publications" + } + list.ColumnStyles.Add(New ColumnStyle(SizeType.Percent, 100)) + + For Each found In result.Matches + If found.Manuscript.PublicationMatches.Contains(found.Match) Then + AddRow(list, CreateMatchCard(found.Manuscript, found.Match)) + End If + Next + + If result.Failures.Count > 0 Then + Dim failures As String = String.Join(Environment.NewLine, + result.Failures.Select(Function(failure) "• " & If(failure.Manuscript Is Nothing, String.Empty, ReminderService.SafeManuscriptTitle(failure.Manuscript) & ": ") & failure.Reason)) + AddRow(list, New Label With { + .Text = "Not checked" & Environment.NewLine & failures, + .AutoSize = True, + .MaximumSize = New Size(660, 0), + .UseMnemonic = False, + .ForeColor = SystemColors.GrayText, + .Margin = New Padding(0, 8, 0, 0) + }) + End If + + scroller.Controls.Add(list) + body.Controls.Add(scroller) + UiPolish.ApplyDialog(Me) + btnPrimary.Focus() + + End Sub + + + Private Shared Sub AddRow(list As TableLayoutPanel, control As Control) + list.RowStyles.Add(New RowStyle(SizeType.AutoSize)) + list.Controls.Add(control, 0, list.RowCount) + list.RowCount += 1 + End Sub + + + Friend Shared Function Summary(result As PublicationCheckResult) As String + + Dim checkedText As String = "Checked " & result.Checked.ToString(CultureInfo.CurrentCulture) & + If(result.Checked = 1, " manuscript. ", " manuscripts. ") + Dim found As String + Select Case result.Matches.Count + Case 0 : found = "No new possible publications." + Case 1 : found = "1 possible publication to review." + Case Else : found = result.Matches.Count.ToString(CultureInfo.CurrentCulture) & " possible publications to review." + End Select + + Dim text As String = checkedText & found + If result.Matches.Count > 0 Then text &= " Any you leave stay on the Deadlines page, under No date." + If Not String.IsNullOrWhiteSpace(result.StoppedReason) Then text &= Environment.NewLine & "Stopped early: " & result.StoppedReason + Return text + + End Function + + + Private Function CreateMatchCard(manuscript As Manuscript, match As PublicationMatch) As Control + + Dim card As New SectionCard With { + .Text = ReminderService.SafeManuscriptTitle(manuscript).Replace("&", "&&"), + .Dock = DockStyle.Fill, + .AutoSize = True, + .AutoSizeMode = AutoSizeMode.GrowAndShrink, + .Padding = New Padding(14), + .Margin = New Padding(0, 0, 0, 10) + } + + Dim layout As New TableLayoutPanel With {.Dock = DockStyle.Top, .AutoSize = True, .AutoSizeMode = AutoSizeMode.GrowAndShrink, .ColumnCount = 1, .Margin = New Padding(0)} + layout.ColumnStyles.Add(New ColumnStyle(SizeType.Percent, 100)) + + layout.Controls.Add(New Label With {.Text = "A publication matching this manuscript may have appeared.", .AutoSize = True, .UseMnemonic = False, .Margin = New Padding(0, 0, 0, 4)}) + layout.Controls.Add(New Label With {.Text = PublicationActions.Describe(match), .AutoSize = True, .MaximumSize = New Size(620, 0), .UseMnemonic = False, .Font = New Font(Font, FontStyle.Bold), .Margin = New Padding(0, 0, 0, 2)}) + layout.Controls.Add(New Label With {.Text = PublicationActions.SourceText(match), .AutoSize = True, .UseMnemonic = False, .ForeColor = SystemColors.GrayText, .Margin = New Padding(0, 0, 0, 8)}) + + Dim actions As New FlowLayoutPanel With {.AutoSize = True, .AutoSizeMode = AutoSizeMode.GrowAndShrink, .WrapContents = False, .Margin = New Padding(0)} + Dim outcome As New Label With {.AutoSize = True, .UseMnemonic = False, .Visible = False, .Margin = New Padding(0, 6, 0, 0)} + + Dim review As New ActionButton With {.Text = "Review Match", .Height = 30, .Width = 118, .Margin = New Padding(0, 0, 8, 0), .Enabled = PublicationActions.MatchUri(match) IsNot Nothing} + AddHandler review.Click, Sub(sender, e) PublicationActions.OpenMatch(Me, match) + Dim mark As New ActionButton With {.Text = "Mark Published...", .Height = 30, .Width = 138, .Margin = New Padding(0, 0, 8, 0)} + Dim ignore As New ActionButton With {.Text = "Ignore", .Height = 30, .Width = 80, .Margin = New Padding(0)} + + AddHandler mark.Click, + Sub(sender, e) + If MarkPublished(manuscript, match) Then + actions.Visible = False + outcome.Text = "Marked published. It is on the Published shelf." + outcome.Visible = True + End If + End Sub + AddHandler ignore.Click, + Sub(sender, e) + Dim target As PublicationMatch = match + If PublicationActions.Commit(_library, manuscript, Sub() PublicationMatchService.Ignore(target), _save) Then + Changed = True + actions.Visible = False + outcome.Text = "Ignored. Later checks will not show this match again." + outcome.Visible = True + End If + End Sub + + actions.Controls.Add(review) + actions.Controls.Add(mark) + actions.Controls.Add(ignore) + layout.Controls.Add(actions) + layout.Controls.Add(outcome) + card.Controls.Add(layout) + Return card + + End Function + + + Friend Function MarkPublished(manuscript As Manuscript, match As PublicationMatch) As Boolean + + Dim confirmed As Boolean + If ConfirmMarkPublished IsNot Nothing Then + confirmed = ConfirmMarkPublished(manuscript, match) + Else + Using dialog As New MarkPublishedForm(manuscript, match, _today) + confirmed = dialog.ShowDialog(Me) = DialogResult.OK + End Using + End If + If Not confirmed Then Return False + + If Not PublicationActions.Commit(_library, manuscript, Sub() PublicationMatchService.MarkPublished(manuscript, match, _today), _save) Then Return False + Changed = True + Return True + + End Function + + + Private Sub ClearBody() + For Each child As Control In body.Controls.Cast(Of Control)().ToList() + body.Controls.Remove(child) + If child IsNot lstManuscripts Then child.Dispose() + Next + End Sub + + + Protected Overrides Sub OnFormClosing(e As FormClosingEventArgs) + ' Closing while checking stops the check first. + If _cancellation IsNot Nothing Then + _cancellation.Cancel() + e.Cancel = True + Return + End If + MyBase.OnFormClosing(e) + End Sub + + End Class + +End Namespace diff --git a/ManuscriptPipeline/Forms/RemindersForm.vb b/ManuscriptPipeline/Forms/RemindersForm.vb deleted file mode 100644 index 3f59634..0000000 --- a/ManuscriptPipeline/Forms/RemindersForm.vb +++ /dev/null @@ -1,941 +0,0 @@ -Imports System -Imports System.Collections.Generic -Imports System.Drawing -Imports System.IO -Imports System.Linq -Imports System.Windows.Forms -Imports ManuscriptPipeline.Controls -Imports ManuscriptPipeline.Models -Imports ManuscriptPipeline.Services - -Namespace Forms - - Public Class RemindersForm - Inherits Form - - Private ReadOnly _manuscripts As List(Of Manuscript) - Private ReadOnly _repository As ManuscriptRepository - - Private ReadOnly cboFilter As New ComboBox() - Private ReadOnly lblSummary As New Label() - Private ReadOnly grid As New DataGridView() - - Private _occurrences As New List(Of ReminderOccurrence)() - - - Public Sub New( - manuscripts As List(Of Manuscript), - repository As ManuscriptRepository - ) - - If manuscripts Is Nothing Then - Throw New ArgumentNullException(NameOf(manuscripts)) - End If - - If repository Is Nothing Then - Throw New ArgumentNullException(NameOf(repository)) - End If - - _manuscripts = - manuscripts - - _repository = - repository - - BuildInterface() - EmptyHint.Attach(grid, "No reminders to show. Revision deadlines and follow-up dates appear here automatically; use Add Reminder... for your own.") - UiPolish.ApplyDialog(Me) - RefreshGrid() - - End Sub - - - Private Sub BuildInterface() - - Me.Text = - "Reminders & Calendar" - - Me.StartPosition = - FormStartPosition.CenterParent - - Me.Size = - New Size( - 1080, - 690 - ) - - Me.MinimumSize = - New Size( - 820, - 520 - ) - - Me.Font = - New Font( - "Segoe UI", - 10.0F - ) - - Me.AutoScaleMode = - AutoScaleMode.Dpi - - Dim root As New TableLayoutPanel With { - .Dock = DockStyle.Fill, - .ColumnCount = 1, - .RowCount = 4, - .Padding = New Padding(18) - } - - root.RowStyles.Add(New RowStyle(SizeType.AutoSize)) - root.RowStyles.Add(New RowStyle(SizeType.AutoSize)) - root.RowStyles.Add(New RowStyle(SizeType.Percent, 100)) - root.RowStyles.Add(New RowStyle(SizeType.AutoSize)) - - Dim intro As New Label With { - .Text = - "PaperRoute combines revision deadlines, journal follow-up dates, and your own custom reminders. " & - "The list is local; calendar export creates a portable .ics file.", - .AutoSize = True, - .MaximumSize = New Size(1000, 0), - .Margin = New Padding(0, 0, 0, 10) - } - - Dim filterBar As New FlowLayoutPanel With { - .Dock = DockStyle.Fill, - .AutoSize = True, - .FlowDirection = FlowDirection.LeftToRight, - .WrapContents = True, - .Margin = New Padding(0, 0, 0, 8) - } - - Dim lblFilter As New Label With { - .Text = "Show", - .AutoSize = True, - .Anchor = AnchorStyles.Left, - .Font = - New Font( - Me.Font, - FontStyle.Bold - ), - .Margin = New Padding(0, 7, 8, 0) - } - - cboFilter.DropDownStyle = - ComboBoxStyle.DropDownList - - cboFilter.Width = - 210 - - cboFilter.Items.Add( - "All active reminders" - ) - - cboFilter.Items.Add( - "Overdue / due today" - ) - - cboFilter.Items.Add( - "Upcoming" - ) - - cboFilter.SelectedIndex = - 0 - - AddHandler cboFilter.SelectedIndexChanged, - Sub(sender, e) - RefreshGrid() - End Sub - - lblSummary.AutoSize = - True - - lblSummary.ForeColor = - SystemColors.GrayText - - lblSummary.Margin = - New Padding(18, 7, 0, 0) - - filterBar.Controls.Add( - lblFilter - ) - - filterBar.Controls.Add( - cboFilter - ) - - filterBar.Controls.Add( - lblSummary - ) - - ConfigureGrid() - - Dim footer As New TableLayoutPanel With { - .Dock = DockStyle.Fill, - .AutoSize = True, - .ColumnCount = 1, - .RowCount = 2, - .Padding = New Padding(0, 10, 0, 0) - } - - footer.RowStyles.Add( - New RowStyle( - SizeType.AutoSize - ) - ) - - footer.RowStyles.Add( - New RowStyle( - SizeType.AutoSize - ) - ) - - Dim help As New Label With { - .Text = - "Revision deadlines come from editorial decisions. Journal follow-ups come from submission records. " & - "Custom reminders can be edited or completed here.", - .AutoSize = True, - .MaximumSize = New Size(1000, 0), - .ForeColor = SystemColors.GrayText, - .Margin = New Padding(0, 0, 0, 8) - } - - Dim buttonBar As New FlowLayoutPanel With { - .Dock = DockStyle.Fill, - .AutoSize = True, - .FlowDirection = FlowDirection.LeftToRight, - .WrapContents = True - } - - Dim btnAdd As New Button With { - .Text = "Add Reminder...", - .AutoSize = True, - .Height = 36 - } - - Dim btnEdit As New Button With { - .Text = "Edit Custom...", - .AutoSize = True, - .Height = 36 - } - - Dim btnComplete As New Button With { - .Text = "Complete Custom", - .AutoSize = True, - .Height = 36 - } - - Dim btnExport As New Button With { - .Text = "Export Calendar (.ics)...", - .AutoSize = True, - .Height = 36 - } - - Dim btnRefresh As New Button With { - .Text = "Refresh", - .AutoSize = True, - .Height = 36 - } - - Dim btnClose As New Button With { - .Text = "Close", - .AutoSize = True, - .Height = 36, - .DialogResult = DialogResult.OK - } - - AddHandler btnAdd.Click, - AddressOf AddReminder - - AddHandler btnEdit.Click, - AddressOf EditReminder - - AddHandler btnComplete.Click, - AddressOf CompleteReminder - - AddHandler btnExport.Click, - AddressOf ExportCalendar - - AddHandler btnRefresh.Click, - Sub(sender, e) - RefreshGrid() - End Sub - - buttonBar.Controls.Add(btnAdd) - buttonBar.Controls.Add(btnEdit) - buttonBar.Controls.Add(btnComplete) - buttonBar.Controls.Add(btnExport) - buttonBar.Controls.Add(btnRefresh) - buttonBar.Controls.Add(btnClose) - - footer.Controls.Add( - help, - 0, - 0 - ) - - footer.Controls.Add( - buttonBar, - 0, - 1 - ) - - root.Controls.Add(intro, 0, 0) - root.Controls.Add(filterBar, 0, 1) - root.Controls.Add(grid, 0, 2) - root.Controls.Add(footer, 0, 3) - - Me.CancelButton = - btnClose - - Me.Controls.Add( - root - ) - - End Sub - - - Private Sub ConfigureGrid() - - grid.Dock = - DockStyle.Fill - - grid.ReadOnly = - True - - grid.AllowUserToAddRows = - False - - grid.AllowUserToDeleteRows = - False - - grid.AllowUserToResizeRows = - False - - grid.AutoGenerateColumns = - False - - grid.MultiSelect = - False - - grid.SelectionMode = - DataGridViewSelectionMode.FullRowSelect - - grid.RowHeadersVisible = - False - - grid.AutoSizeColumnsMode = - DataGridViewAutoSizeColumnsMode.None - - grid.Columns.Add( - CreateTextColumn( - "Due", - 110 - ) - ) - - grid.Columns.Add( - CreateTextColumn( - "Status", - 105 - ) - ) - - grid.Columns.Add( - CreateTextColumn( - "Type", - 100 - ) - ) - - grid.Columns.Add( - CreateTextColumn( - "Manuscript", - 260 - ) - ) - - grid.Columns.Add( - CreateTextColumn( - "Reminder", - 270 - ) - ) - - grid.Columns.Add( - CreateTextColumn( - "Journal", - 170 - ) - ) - - AddHandler grid.CellDoubleClick, - Sub(sender, e) - - If e.RowIndex < 0 Then - Return - End If - - EditReminder( - sender, - EventArgs.Empty - ) - - End Sub - - End Sub - - - Private Function CreateTextColumn( - header As String, - width As Integer - ) As DataGridViewTextBoxColumn - - Return New DataGridViewTextBoxColumn With { - .HeaderText = header, - .Width = width, - .SortMode = DataGridViewColumnSortMode.NotSortable - } - - End Function - - - Private Sub RefreshGrid() - - _occurrences = - ReminderService.BuildOccurrences( - _manuscripts, - DateTime.Today - ) - - Dim filtered As IEnumerable(Of ReminderOccurrence) = - _occurrences - - Select Case cboFilter.SelectedIndex - - Case 1 - - filtered = - filtered.Where( - Function(item) - Return item.Status = - ReminderStatus.Overdue OrElse - item.Status = - ReminderStatus.DueToday - End Function - ) - - Case 2 - - filtered = - filtered.Where( - Function(item) - Return item.Status = - ReminderStatus.Upcoming - End Function - ) - - End Select - - Dim visible As List(Of ReminderOccurrence) = - filtered.ToList() - - grid.Rows.Clear() - - For Each item As ReminderOccurrence In visible - - Dim rowIndex As Integer = - grid.Rows.Add( - item.DueDate.ToString( - "yyyy-MM-dd" - ), - item.StatusLabel, - item.KindLabel, - item.ManuscriptTitle, - item.Title, - item.JournalName - ) - - grid.Rows(rowIndex).Tag = - item - - Next - - Dim overdueCount As Integer = - _occurrences. - Where( - Function(item) - Return item.Status = - ReminderStatus.Overdue - End Function - ). - Count() - - Dim dueTodayCount As Integer = - _occurrences. - Where( - Function(item) - Return item.Status = - ReminderStatus.DueToday - End Function - ). - Count() - - lblSummary.Text = - visible.Count.ToString() & - " shown • " & - overdueCount.ToString() & - " overdue • " & - dueTodayCount.ToString() & - " due today" - - End Sub - - - Private Function SelectedOccurrence() As ReminderOccurrence - - If grid.SelectedRows.Count = 0 Then - Return Nothing - End If - - Return TryCast( - grid.SelectedRows(0).Tag, - ReminderOccurrence - ) - - End Function - - - Private Sub AddReminder( - sender As Object, - e As EventArgs - ) - - If _manuscripts.Count = 0 Then - - MessageBox.Show( - Me, - "Add a manuscript before creating a reminder.", - "No Manuscripts", - MessageBoxButtons.OK, - MessageBoxIcon.Information - ) - - Return - - End If - - Using dialog As New ReminderEditForm( - _manuscripts - ) - - If dialog.ShowDialog(Me) <> - DialogResult.OK OrElse - dialog.Result Is Nothing Then - - Return - - End If - - Dim manuscript As Manuscript = - _manuscripts. - FirstOrDefault( - Function(item) - Return item.Id = - dialog.SelectedManuscriptId - End Function - ) - - If manuscript Is Nothing Then - Return - End If - - If manuscript.Reminders Is Nothing Then - - manuscript.Reminders = - New List(Of ManuscriptReminder)() - - End If - - manuscript.Reminders.Add( - dialog.Result - ) - - Try - - _repository.Save( - _manuscripts - ) - - Catch ex As Exception - - manuscript.Reminders.Remove( - dialog.Result - ) - - ShowSaveError( - ex - ) - - Return - - End Try - - RefreshGrid() - - End Using - - End Sub - - - Private Sub EditReminder( - sender As Object, - e As EventArgs - ) - - Dim occurrence As ReminderOccurrence = - SelectedOccurrence() - - If occurrence Is Nothing Then - Return - End If - - If occurrence.Kind <> - ReminderKind.Custom Then - - ShowSourceReminderHelp( - occurrence - ) - - Return - - End If - - Dim manuscript As Manuscript = - FindManuscript( - occurrence.ManuscriptId - ) - - If manuscript Is Nothing OrElse - manuscript.Reminders Is Nothing Then - - Return - - End If - - Dim existing As ManuscriptReminder = - manuscript.Reminders. - FirstOrDefault( - Function(item) - Return item IsNot Nothing AndAlso - item.Id = - occurrence.SourceId - End Function - ) - - If existing Is Nothing Then - Return - End If - - Using dialog As New ReminderEditForm( - _manuscripts, - manuscript, - existing - ) - - If dialog.ShowDialog(Me) <> - DialogResult.OK OrElse - dialog.Result Is Nothing Then - - Return - - End If - - Dim index As Integer = - manuscript.Reminders.FindIndex( - Function(item) - Return item IsNot Nothing AndAlso - item.Id = - existing.Id - End Function - ) - - If index < 0 Then - Return - End If - - manuscript.Reminders(index) = - dialog.Result - - Try - - _repository.Save( - _manuscripts - ) - - Catch ex As Exception - - manuscript.Reminders(index) = - existing - - ShowSaveError( - ex - ) - - Return - - End Try - - RefreshGrid() - - End Using - - End Sub - - - Private Sub CompleteReminder( - sender As Object, - e As EventArgs - ) - - Dim occurrence As ReminderOccurrence = - SelectedOccurrence() - - If occurrence Is Nothing Then - Return - End If - - If occurrence.Kind <> - ReminderKind.Custom Then - - ShowSourceReminderHelp( - occurrence - ) - - Return - - End If - - Dim manuscript As Manuscript = - FindManuscript( - occurrence.ManuscriptId - ) - - If manuscript Is Nothing OrElse - manuscript.Reminders Is Nothing Then - - Return - - End If - - Dim reminder As ManuscriptReminder = - manuscript.Reminders. - FirstOrDefault( - Function(item) - Return item IsNot Nothing AndAlso - item.Id = - occurrence.SourceId - End Function - ) - - If reminder Is Nothing Then - Return - End If - - Dim oldCompleted As Boolean = - reminder.IsCompleted - - Dim oldCompletedDate As DateTime? = - reminder.CompletedDate - - reminder.IsCompleted = - True - - reminder.CompletedDate = - DateTime.Now - - Try - - _repository.Save( - _manuscripts - ) - - Catch ex As Exception - - reminder.IsCompleted = - oldCompleted - - reminder.CompletedDate = - oldCompletedDate - - ShowSaveError( - ex - ) - - Return - - End Try - - RefreshGrid() - - End Sub - - - Private Sub ExportCalendar( - sender As Object, - e As EventArgs - ) - - Dim allActive As List(Of ReminderOccurrence) = - ReminderService.BuildOccurrences( - _manuscripts, - DateTime.Today - ) - - If allActive.Count = 0 Then - - MessageBox.Show( - Me, - "There are no active reminders to export.", - "No Calendar Events", - MessageBoxButtons.OK, - MessageBoxIcon.Information - ) - - Return - - End If - - Using picker As New SaveFileDialog With { - .Title = "Export PaperRoute Reminder Calendar", - .Filter = "iCalendar file (*.ics)|*.ics", - .DefaultExt = "ics", - .AddExtension = True, - .OverwritePrompt = True, - .FileName = "PaperRoute-Reminders.ics" - } - - If picker.ShowDialog(Me) <> - DialogResult.OK Then - - Return - - End If - - Try - - File.WriteAllText( - picker.FileName, - IcsCalendarService.Export( - allActive - ) - ) - - MessageBox.Show( - Me, - "Calendar exported successfully." & - Environment.NewLine & - Environment.NewLine & - "The .ics file can be imported into Outlook, Google Calendar, Apple Calendar, and other compatible calendar apps.", - "Calendar Export Complete", - MessageBoxButtons.OK, - MessageBoxIcon.Information - ) - - Catch ex As Exception - - MessageBox.Show( - Me, - "PaperRoute could not create the calendar file." & - Environment.NewLine & - Environment.NewLine & - ex.Message, - "Calendar Export Error", - MessageBoxButtons.OK, - MessageBoxIcon.Error - ) - - End Try - - End Using - - End Sub - - - Private Function FindManuscript( - manuscriptId As Guid - ) As Manuscript - - Return _manuscripts. - FirstOrDefault( - Function(item) - Return item IsNot Nothing AndAlso - item.Id = - manuscriptId - End Function - ) - - End Function - - - Private Sub ShowSourceReminderHelp( - occurrence As ReminderOccurrence - ) - - Dim message As String - - Select Case occurrence.Kind - - Case ReminderKind.RevisionDeadline - - message = - "This reminder comes from the manuscript's recorded revision deadline." & - Environment.NewLine & - Environment.NewLine & - "Edit the relevant editorial decision or revision workflow to change the deadline." - - Case ReminderKind.SubmissionFollowUp - - message = - "This reminder comes from the journal submission's follow-up date." & - Environment.NewLine & - Environment.NewLine & - "Edit that journal submission to change or remove the follow-up date." - - Case Else - - message = - "This reminder is managed by its source record." - - End Select - - MessageBox.Show( - Me, - message, - "Source Reminder", - MessageBoxButtons.OK, - MessageBoxIcon.Information - ) - - End Sub - - - Private Sub ShowSaveError( - ex As Exception - ) - - MessageBox.Show( - Me, - "PaperRoute could not save the reminder change." & - Environment.NewLine & - Environment.NewLine & - ex.Message, - "Reminder Save Error", - MessageBoxButtons.OK, - MessageBoxIcon.Error - ) - - End Sub - - End Class - -End Namespace diff --git a/ManuscriptPipeline/Forms/SettingsForm.vb b/ManuscriptPipeline/Forms/SettingsForm.vb index 76f34ef..ad6fbf1 100644 --- a/ManuscriptPipeline/Forms/SettingsForm.vb +++ b/ManuscriptPipeline/Forms/SettingsForm.vb @@ -408,7 +408,7 @@ Namespace Forms Dim help As New Label With { .Text = - "Notifications are optional. The Reminders page remains available even if Windows suppresses notifications.", + "Notifications are optional. The Deadlines page remains available even if Windows suppresses notifications.", .AutoSize = True, .UseMnemonic = False, .MaximumSize = New Size(680, 0), diff --git a/ManuscriptPipeline/Forms/SubmissionDetailsForm.vb b/ManuscriptPipeline/Forms/SubmissionDetailsForm.vb index 89bbc27..72cd736 100644 --- a/ManuscriptPipeline/Forms/SubmissionDetailsForm.vb +++ b/ManuscriptPipeline/Forms/SubmissionDetailsForm.vb @@ -28,6 +28,20 @@ Namespace Forms ' Raised after a decision, correspondence, or reviewer-response change. Friend Event Changed As EventHandler + + ' Brings the inline Reviewer Responses tab forward, for a revision + ' deadline opened from the Deadlines page. + Friend Sub ShowReviewerResponses() + + Dim page = TryCast(_responsesMatrix?.Parent, TabPage) + Dim tabs = TryCast(page?.Parent, TabControl) + + If tabs IsNot Nothing Then + tabs.SelectedTab = page + End If + + End Sub + ' Raised inline instead of closing when another workflow window is requested. Friend Event NavigationRequested(request As SubmissionWorkflowRequest) Public ReadOnly Property RequestedNavigation As SubmissionWorkflowRequest diff --git a/ManuscriptPipeline/ManuscriptPipeline.vbproj b/ManuscriptPipeline/ManuscriptPipeline.vbproj index dcc2277..fbfb676 100644 --- a/ManuscriptPipeline/ManuscriptPipeline.vbproj +++ b/ManuscriptPipeline/ManuscriptPipeline.vbproj @@ -1,9 +1,9 @@ - 0.6.0 - 0.6.0.0 - 0.6.0.0 + 0.7.0-rc.1 + 0.7.0.0 + 0.7.0.0 PaperRouteTracker ManuscriptPipeline PaperRoute Tracker diff --git a/ManuscriptPipeline/Models/CrossrefMetadataSuggestion.vb b/ManuscriptPipeline/Models/CrossrefMetadataSuggestion.vb index 15c2b07..6d1d060 100644 --- a/ManuscriptPipeline/Models/CrossrefMetadataSuggestion.vb +++ b/ManuscriptPipeline/Models/CrossrefMetadataSuggestion.vb @@ -23,6 +23,13 @@ Namespace Models Public Property Url As String = String.Empty + ' Crossref's work type, such as journal-article or posted-content. + Public Property WorkType As String = String.Empty + + ' For a preprint, the DOIs of its published versions. + Public Property PublishedVersionDois As List(Of String) = + New List(Of String)() + Public Property AbstractText As String = String.Empty Public Property Keywords As List(Of String) = diff --git a/ManuscriptPipeline/Models/DeadlineGroup.vb b/ManuscriptPipeline/Models/DeadlineGroup.vb new file mode 100644 index 0000000..94e25c3 --- /dev/null +++ b/ManuscriptPipeline/Models/DeadlineGroup.vb @@ -0,0 +1,13 @@ +Namespace Models + + ' Where an item sits on the Deadlines page, from its date alone. + Public Enum DeadlineGroup + Overdue + Today + Next7Days + Later + NoDate + Done + End Enum + +End Namespace diff --git a/ManuscriptPipeline/Models/DeadlineItem.vb b/ManuscriptPipeline/Models/DeadlineItem.vb new file mode 100644 index 0000000..56330ad --- /dev/null +++ b/ManuscriptPipeline/Models/DeadlineItem.vb @@ -0,0 +1,49 @@ +Imports System + +Namespace Models + + ' One row on the Deadlines page. It is derived from the record that owns + ' it each time the page is built and is never stored. + Public Class DeadlineItem + + Public Property Kind As DeadlineKind + + Public Property Group As DeadlineGroup + + ' Nothing for work that has no date yet. + Public Property DueDate As DateTime? = Nothing + + Public Property Title As String = String.Empty + + Public Property ManuscriptId As Guid + + Public Property ManuscriptTitle As String = String.Empty + + Public Property JournalName As String = String.Empty + + ' The records that own the item, for opening it where it lives. + Public Property SubmissionId As Guid? = Nothing + + Public Property DecisionId As Guid? = Nothing + + Public Property PacketId As Guid? = Nothing + + Public Property ReminderId As Guid? = Nothing + + Public Property PublicationMatchId As Guid? = Nothing + + ' Progress, when the item has parts: reviewer comments for a revision + ' (addressed or not applicable count as done) or required checklist + ' items for a packet. A total of zero means no progress is shown. + Public Property ProgressDone As Integer + + Public Property ProgressActive As Integer + + Public Property ProgressTotal As Integer + + ' When a reminder in the Done group was completed. + Public Property CompletedDate As DateTime? = Nothing + + End Class + +End Namespace diff --git a/ManuscriptPipeline/Models/DeadlineKind.vb b/ManuscriptPipeline/Models/DeadlineKind.vb new file mode 100644 index 0000000..029406f --- /dev/null +++ b/ManuscriptPipeline/Models/DeadlineKind.vb @@ -0,0 +1,12 @@ +Namespace Models + + Public Enum DeadlineKind + Revision + FollowUp + Reminder + Preparation + ' A possible publication found by a publication check (#61). + Publication + End Enum + +End Namespace diff --git a/ManuscriptPipeline/Models/Manuscript.vb b/ManuscriptPipeline/Models/Manuscript.vb index 09d922d..99909ba 100644 --- a/ManuscriptPipeline/Models/Manuscript.vb +++ b/ManuscriptPipeline/Models/Manuscript.vb @@ -64,6 +64,10 @@ Namespace Models Public Property Submissions As List(Of JournalSubmission) = New List(Of JournalSubmission)() + ' Possible publications found by a publication check (Schema 7). + Public Property PublicationMatches As List(Of PublicationMatch) = + New List(Of PublicationMatch)() + Public ReadOnly Property SubmissionCount As Integer Get diff --git a/ManuscriptPipeline/Models/PublicationMatch.vb b/ManuscriptPipeline/Models/PublicationMatch.vb new file mode 100644 index 0000000..a8bf598 --- /dev/null +++ b/ManuscriptPipeline/Models/PublicationMatch.vb @@ -0,0 +1,63 @@ +Imports System + +Namespace Models + + ' Where a possible publication was found. + Public Enum PublicationMatchSource + ' The manuscript's own DOI now resolves to a published work. + Doi + ' The manuscript's preprint DOI names its published version. + Preprint + ' A Crossref search by title. + Title + ' A work in an ORCID record. + Orcid + End Enum + + + Public Enum PublicationMatchStatus + ' Found and not yet reviewed; listed on the Deadlines page. + Pending + ' Not this manuscript. Later checks never show it again. + Ignored + ' The manuscript was marked published from this match. + Confirmed + End Enum + + + ' A possible publication found by a user-initiated check (#61). It is a + ' finding, not a status: the manuscript changes only through Mark + ' Published. + Public Class PublicationMatch + + Public Property Id As Guid = Guid.NewGuid() + + Public Property Doi As String = String.Empty + + Public Property Title As String = String.Empty + + Public Property Journal As String = String.Empty + + Public Property PublishedDate As DateTime? = Nothing + + Public Property Url As String = String.Empty + + Public Property Publisher As String = String.Empty + + Public Property Volume As String = String.Empty + + Public Property Issue As String = String.Empty + + Public Property Pages As String = String.Empty + + Public Property Source As PublicationMatchSource = PublicationMatchSource.Title + + Public Property Status As PublicationMatchStatus = PublicationMatchStatus.Pending + + Public Property FoundAtUtc As DateTime = DateTime.UtcNow + + Public Property ReviewedAtUtc As DateTime? = Nothing + + End Class + +End Namespace diff --git a/ManuscriptPipeline/Services/CrossrefApplyService.vb b/ManuscriptPipeline/Services/CrossrefApplyService.vb index 6fbf0d7..94a8e44 100644 --- a/ManuscriptPipeline/Services/CrossrefApplyService.vb +++ b/ManuscriptPipeline/Services/CrossrefApplyService.vb @@ -526,7 +526,7 @@ Namespace Services End Function - Private Shared Sub RecordProvenance( + Friend Shared Sub RecordProvenance( metadata As ManuscriptMetadata, suggestion As CrossrefMetadataSuggestion ) diff --git a/ManuscriptPipeline/Services/CrossrefClient.vb b/ManuscriptPipeline/Services/CrossrefClient.vb index 736a549..7ac8269 100644 --- a/ManuscriptPipeline/Services/CrossrefClient.vb +++ b/ManuscriptPipeline/Services/CrossrefClient.vb @@ -69,17 +69,13 @@ Namespace Services If response.StatusCode = HttpStatusCode.NotFound Then - Throw New InvalidOperationException( - "Crossref did not find a work for that DOI." - ) + Throw New CrossrefNotFoundException() End If If CInt(response.StatusCode) = 429 Then - Throw New InvalidOperationException( - "Crossref is temporarily rate-limiting requests. Please wait a moment and try again." - ) + Throw New CrossrefRateLimitException() End If @@ -142,33 +138,7 @@ Namespace Services End If - Dim suggestion As New CrossrefMetadataSuggestion With { - .Doi = ReadString(message, "DOI"), - .Title = ReadFirstString(message, "title"), - .Journal = ReadFirstString(message, "container-title"), - .Publisher = ReadString(message, "publisher"), - .Volume = ReadString(message, "volume"), - .Issue = ReadString(message, "issue"), - .Pages = ReadString(message, "page"), - .Url = ReadString(message, "URL"), - .AbstractText = CleanAbstract(ReadString(message, "abstract")) - } - - suggestion.PublishedDate = - ReadPublishedDate( - message - ) - - suggestion.Keywords = - ReadStringArray( - message, - "subject" - ) - - suggestion.Authors = - ReadAuthors( - message - ) + Dim suggestion As CrossrefMetadataSuggestion = ParseWork(message) If String.IsNullOrWhiteSpace( suggestion.Doi @@ -187,6 +157,126 @@ Namespace Services End Function + ' One work from /works/{doi} or one item of a search. + Private Shared Function ParseWork(message As JsonElement) As CrossrefMetadataSuggestion + + Dim suggestion As New CrossrefMetadataSuggestion With { + .Doi = ReadString(message, "DOI"), + .Title = ReadFirstString(message, "title"), + .Journal = ReadFirstString(message, "container-title"), + .Publisher = ReadString(message, "publisher"), + .Volume = ReadString(message, "volume"), + .Issue = ReadString(message, "issue"), + .Pages = ReadString(message, "page"), + .Url = ReadString(message, "URL"), + .WorkType = ReadString(message, "type"), + .AbstractText = CleanAbstract(ReadString(message, "abstract")) + } + + suggestion.PublishedDate = ReadPublishedDate(message) + suggestion.Keywords = ReadStringArray(message, "subject") + suggestion.Authors = ReadAuthors(message) + suggestion.PublishedVersionDois = ReadRelatedDois(message, "is-preprint-of") + + Return suggestion + + End Function + + + ' Works found by title, for the publication check (#61). Crossref's + ' bibliographic query ranks by relevance; the caller decides what + ' counts as a match. + Public Async Function SearchByTitleAsync( + title As String, + Optional cancellationToken As CancellationToken = Nothing + ) As Task(Of List(Of CrossrefMetadataSuggestion)) + + If String.IsNullOrWhiteSpace(title) Then + Throw New ArgumentException("A title is required.", NameOf(title)) + End If + + Dim requestUri As String = + "https://api.crossref.org/works?rows=5" & + "&select=DOI,title,container-title,publisher,volume,issue,page,published-print,published-online,published,issued,type,URL" & + "&query.bibliographic=" & Uri.EscapeDataString(title.Trim()) + + Using response As HttpResponseMessage = Await SharedHttpClient.GetAsync(requestUri, cancellationToken) + + If CInt(response.StatusCode) = 429 Then + Throw New CrossrefRateLimitException() + End If + + If Not response.IsSuccessStatusCode Then + Throw New InvalidOperationException( + "Crossref returned HTTP " & CInt(response.StatusCode).ToString() & ". Please try again later.") + End If + + Return ParseSearchJson(Await response.Content.ReadAsStringAsync(cancellationToken)) + + End Using + + End Function + + + Friend Shared Function ParseSearchJson(json As String) As List(Of CrossrefMetadataSuggestion) + + If String.IsNullOrWhiteSpace(json) Then + Throw New InvalidOperationException("Crossref returned an empty response.") + End If + + Dim results As New List(Of CrossrefMetadataSuggestion)() + + Using document As JsonDocument = JsonDocument.Parse(json) + + Dim message As JsonElement + Dim items As JsonElement + + If Not document.RootElement.TryGetProperty("message", message) OrElse + message.ValueKind <> JsonValueKind.Object OrElse + Not message.TryGetProperty("items", items) OrElse + items.ValueKind <> JsonValueKind.Array Then + Throw New InvalidOperationException("Crossref returned an unexpected response.") + End If + + For Each item As JsonElement In items.EnumerateArray() + If item.ValueKind <> JsonValueKind.Object Then Continue For + Dim work As CrossrefMetadataSuggestion = ParseWork(item) + If Not String.IsNullOrWhiteSpace(work.Doi) Then results.Add(work) + Next + + End Using + + Return results + + End Function + + + ' DOIs under relation.{name}, such as a preprint's published version. + Private Shared Function ReadRelatedDois(message As JsonElement, relationName As String) As List(Of String) + + Dim dois As New List(Of String)() + Dim relation As JsonElement + Dim entries As JsonElement + + If Not message.TryGetProperty("relation", relation) OrElse + relation.ValueKind <> JsonValueKind.Object OrElse + Not relation.TryGetProperty(relationName, entries) OrElse + entries.ValueKind <> JsonValueKind.Array Then + Return dois + End If + + For Each entry As JsonElement In entries.EnumerateArray() + If entry.ValueKind <> JsonValueKind.Object Then Continue For + If Not String.Equals(ReadString(entry, "id-type"), "doi", StringComparison.OrdinalIgnoreCase) Then Continue For + Dim doi As String = DoiNormalizer.Normalize(ReadString(entry, "id")) + If DoiNormalizer.IsValid(doi) AndAlso Not dois.Contains(doi, StringComparer.OrdinalIgnoreCase) Then dois.Add(doi) + Next + + Return dois + + End Function + + Private Shared Function ReadString( parent As JsonElement, propertyName As String @@ -542,4 +632,26 @@ Namespace Services End Class + + Public Class CrossrefNotFoundException + Inherits InvalidOperationException + + Public Sub New() + MyBase.New("Crossref did not find a work for that DOI.") + End Sub + + End Class + + + ' Crossref asked PaperRoute to slow down. A publication check stops and + ' keeps what it found so far. + Public Class CrossrefRateLimitException + Inherits InvalidOperationException + + Public Sub New() + MyBase.New("Crossref is temporarily rate-limiting requests. Please wait a moment and try again.") + End Sub + + End Class + End Namespace diff --git a/ManuscriptPipeline/Services/DeadlineService.vb b/ManuscriptPipeline/Services/DeadlineService.vb new file mode 100644 index 0000000..83b8199 --- /dev/null +++ b/ManuscriptPipeline/Services/DeadlineService.vb @@ -0,0 +1,372 @@ +Imports System +Imports System.Collections.Generic +Imports System.Linq +Imports ManuscriptPipeline.Models + +Namespace Services + + ' What needs action, and when (#28). Dated items come only from + ' ReminderService, PaperRoute's one reminder engine. This adds the work + ' that has no date yet and recently completed reminders. It reads the + ' library and never changes it, so building the page stores nothing. + Public NotInheritable Class DeadlineService + + ' "Next 7 days" counts from today, so it never shrinks at the end of + ' a calendar week. + Public Const NearDays As Integer = 7 + + ' Completed reminders stay in the Done group this long. + Public Const DoneDays As Integer = 30 + + Private Sub New() + End Sub + + + Public Shared Function Build( + manuscripts As IEnumerable(Of Manuscript), + asOfDate As DateTime + ) As List(Of DeadlineItem) + + If manuscripts Is Nothing Then + Throw New ArgumentNullException(NameOf(manuscripts)) + End If + + Dim today As DateTime = asOfDate.Date + Dim library As List(Of Manuscript) = manuscripts.Where(Function(item) item IsNot Nothing).ToList() + Dim items As New List(Of DeadlineItem)() + + For Each occurrence As ReminderOccurrence In ReminderService.BuildOccurrences(library, today) + Dim owner As Manuscript = library.FirstOrDefault(Function(item) item.Id = occurrence.ManuscriptId) + items.Add(FromOccurrence(occurrence, owner, today)) + Next + + For Each manuscript As Manuscript In library + AddRevisionWithoutDeadline(items, manuscript) + AddPacketPreparation(items, manuscript) + AddCompletedReminders(items, manuscript, today) + AddPossiblePublications(items, manuscript) + Next + + Return items. + OrderBy(Function(item) item.Group). + ThenBy(Function(item) SortKey(item)). + ThenBy(Function(item) item.ManuscriptTitle, StringComparer.CurrentCultureIgnoreCase). + ThenBy(Function(item) item.Title, StringComparer.CurrentCultureIgnoreCase). + ToList() + + End Function + + + ' Overdue and due today: the rail badge. Undated work never counts. + Public Shared Function CountDueNow( + manuscripts As IEnumerable(Of Manuscript), + asOfDate As DateTime + ) As Integer + + If manuscripts Is Nothing Then + Throw New ArgumentNullException(NameOf(manuscripts)) + End If + + Return ReminderService.BuildOccurrences(manuscripts.Where(Function(item) item IsNot Nothing).ToList(), asOfDate.Date). + Where(Function(occurrence) occurrence.DueDate.Date <= asOfDate.Date).Count() + + End Function + + + Public Shared Function GroupFor(dueDate As DateTime, asOfDate As DateTime) As DeadlineGroup + + Dim days As Integer = (dueDate.Date - asOfDate.Date).Days + + If days < 0 Then Return DeadlineGroup.Overdue + If days = 0 Then Return DeadlineGroup.Today + If days <= NearDays Then Return DeadlineGroup.Next7Days + Return DeadlineGroup.Later + + End Function + + + ' Dated groups run soonest first; Done runs most recently completed first. + Private Shared Function SortKey(item As DeadlineItem) As Long + If item.Group = DeadlineGroup.Done Then + Return -If(item.CompletedDate, DateTime.MinValue).Ticks + End If + Return If(item.DueDate, DateTime.MaxValue).Ticks + End Function + + + Private Shared Function FromOccurrence( + occurrence As ReminderOccurrence, + manuscript As Manuscript, + today As DateTime + ) As DeadlineItem + + Dim item As New DeadlineItem With { + .Group = GroupFor(occurrence.DueDate, today), + .DueDate = occurrence.DueDate.Date, + .Title = occurrence.Title, + .ManuscriptId = occurrence.ManuscriptId, + .ManuscriptTitle = occurrence.ManuscriptTitle, + .JournalName = occurrence.JournalName, + .SubmissionId = occurrence.SubmissionId + } + + Select Case occurrence.Kind + + Case ReminderKind.RevisionDeadline + ' The deadline belongs to the latest decision, and so do the + ' reviewer comments that make up the revision. + Dim submission As JournalSubmission = ManuscriptAttentionService.GetLatestSubmission(manuscript) + Dim decision As EditorialDecisionEvent = ManuscriptAttentionService.GetLatestDecision(submission) + item.Kind = DeadlineKind.Revision + item.Title = "Revision due" + item.DecisionId = If(decision Is Nothing, CType(Nothing, Guid?), decision.Id) + SetCommentProgress(item, submission, decision) + + Case ReminderKind.SubmissionFollowUp + item.Kind = DeadlineKind.FollowUp + item.Title = If(String.IsNullOrWhiteSpace(occurrence.JournalName), + "Follow up on the submission", + "Follow up with " & occurrence.JournalName) + + Case Else + item.Kind = DeadlineKind.Reminder + item.ReminderId = occurrence.SourceId + + End Select + + Return item + + End Function + + + ' A revision is under way but no deadline is recorded. It stays visible + ' without an invented date. + Private Shared Sub AddRevisionWithoutDeadline(items As List(Of DeadlineItem), manuscript As Manuscript) + + If manuscript.CurrentStage <> PaperStage.Revision Then Return + + Dim submission As JournalSubmission = ManuscriptAttentionService.GetLatestSubmission(manuscript) + Dim decision As EditorialDecisionEvent = ManuscriptAttentionService.GetLatestDecision(submission) + + ' The same rule ReminderService uses for a dated revision, so an + ' obligation is never listed twice. + If (decision IsNot Nothing AndAlso decision.RevisionDeadline.HasValue) OrElse + manuscript.RevisionDeadline.HasValue Then + Return + End If + + Dim item As New DeadlineItem With { + .Kind = DeadlineKind.Revision, + .Group = DeadlineGroup.NoDate, + .Title = "Revision has no deadline", + .ManuscriptId = manuscript.Id, + .ManuscriptTitle = ReminderService.SafeManuscriptTitle(manuscript), + .JournalName = ReminderService.JournalFor(manuscript, submission), + .SubmissionId = If(submission Is Nothing, CType(Nothing, Guid?), submission.Id), + .DecisionId = If(decision Is Nothing, CType(Nothing, Guid?), decision.Id) + } + + SetCommentProgress(item, submission, decision) + items.Add(item) + + End Sub + + + ' A possible publication found by a publication check and not yet + ' reviewed. It has no date; the choice is Mark Published or Ignore. + Private Shared Sub AddPossiblePublications(items As List(Of DeadlineItem), manuscript As Manuscript) + + For Each match As PublicationMatch In PublicationMatchService.PendingMatches(manuscript) + items.Add(New DeadlineItem With { + .Kind = DeadlineKind.Publication, + .Group = DeadlineGroup.NoDate, + .Title = If(String.IsNullOrWhiteSpace(match.Journal), "May have been published", "May have been published in " & match.Journal.Trim()), + .ManuscriptId = manuscript.Id, + .ManuscriptTitle = ReminderService.SafeManuscriptTitle(manuscript), + .JournalName = match.Journal, + .PublicationMatchId = match.Id + }) + Next + + End Sub + + + ' A packet prepared for a journal but not yet submitted, whose + ' checklist still has required items open. + Private Shared Sub AddPacketPreparation(items As List(Of DeadlineItem), manuscript As Manuscript) + + If manuscript.Location <> ManuscriptLocation.Pipeline OrElse + manuscript.CurrentStage >= PaperStage.Accepted OrElse + manuscript.SubmissionPackets Is Nothing OrElse + manuscript.ReadinessProfiles Is Nothing Then + Return + End If + + For Each packet As SubmissionPacket In manuscript.SubmissionPackets + + If packet Is Nothing OrElse packet.SubmissionId.HasValue OrElse Not packet.ReadinessProfileId.HasValue Then + Continue For + End If + + Dim profile As ManuscriptReadiness = + manuscript.ReadinessProfiles.FirstOrDefault(Function(candidate) candidate IsNot Nothing AndAlso candidate.Id = packet.ReadinessProfileId.Value) + + If profile Is Nothing Then Continue For + + Dim summary As ReadinessSummary = SubmissionReadinessService.GetSummary(profile) + If summary.RequiredUnresolved = 0 Then Continue For + + Dim journal As String = If(String.IsNullOrWhiteSpace(packet.JournalName), If(profile.JournalName, String.Empty), packet.JournalName).Trim() + Dim open As String = summary.RequiredUnresolved.ToString() & + If(summary.RequiredUnresolved = 1, " checklist item open", " checklist items open") + + items.Add(New DeadlineItem With { + .Kind = DeadlineKind.Preparation, + .Group = DeadlineGroup.NoDate, + .Title = If(journal.Length = 0, "Packet: ", "Packet for " & journal & ": ") & open, + .ManuscriptId = manuscript.Id, + .ManuscriptTitle = ReminderService.SafeManuscriptTitle(manuscript), + .JournalName = journal, + .PacketId = packet.Id, + .ProgressDone = summary.RequiredResolved, + .ProgressTotal = summary.RequiredTotal + }) + + Next + + End Sub + + + Private Shared Sub AddCompletedReminders(items As List(Of DeadlineItem), manuscript As Manuscript, today As DateTime) + + If manuscript.Reminders Is Nothing Then Return + + For Each reminder As ManuscriptReminder In manuscript.Reminders + + If reminder Is Nothing OrElse Not reminder.IsCompleted OrElse Not reminder.CompletedDate.HasValue Then + Continue For + End If + + Dim completed As DateTime = reminder.CompletedDate.Value.Date + If completed < today.AddDays(-DoneDays) OrElse completed > today Then Continue For + + items.Add(New DeadlineItem With { + .Kind = DeadlineKind.Reminder, + .Group = DeadlineGroup.Done, + .DueDate = reminder.DueDate.Date, + .CompletedDate = completed, + .Title = If(String.IsNullOrWhiteSpace(reminder.Title), "Reminder", reminder.Title.Trim()), + .ManuscriptId = manuscript.Id, + .ManuscriptTitle = ReminderService.SafeManuscriptTitle(manuscript), + .ReminderId = reminder.Id + }) + + Next + + End Sub + + + ' Postpone, Set deadline, and Clear change the date on the record that + ' owns the item, so Deadlines and the manuscript page always agree. + ' Returns the previous date so a failed save can put it back. + Public Shared Function SetDate(manuscript As Manuscript, item As DeadlineItem, newDate As DateTime?) As DateTime? + + If manuscript Is Nothing Then Throw New ArgumentNullException(NameOf(manuscript)) + If item Is Nothing Then Throw New ArgumentNullException(NameOf(item)) + + Dim value As DateTime? = If(newDate.HasValue, newDate.Value.Date, CType(Nothing, DateTime?)) + Dim previous As DateTime? + + Select Case item.Kind + + Case DeadlineKind.FollowUp + Dim submission As JournalSubmission = FindSubmission(manuscript, item.SubmissionId) + If submission Is Nothing Then Throw New InvalidOperationException("The submission for this follow-up no longer exists.") + previous = submission.FollowUpDate + submission.FollowUpDate = value + + Case DeadlineKind.Revision + ' The deadline lives on the decision, unless an older record + ' keeps it on the manuscript. + Dim decision As EditorialDecisionEvent = FindDecision(manuscript, item.DecisionId) + If decision IsNot Nothing AndAlso (decision.RevisionDeadline.HasValue OrElse Not manuscript.RevisionDeadline.HasValue) Then + previous = decision.RevisionDeadline + decision.RevisionDeadline = value + Else + previous = manuscript.RevisionDeadline + manuscript.RevisionDeadline = value + End If + + Case DeadlineKind.Reminder + Dim reminder As ManuscriptReminder = FindReminder(manuscript, item.ReminderId) + If reminder Is Nothing Then Throw New InvalidOperationException("This reminder no longer exists.") + If Not value.HasValue Then Throw New ArgumentException("A reminder always has a date.", NameOf(newDate)) + previous = reminder.DueDate + reminder.DueDate = value.Value + + Case Else + Throw New InvalidOperationException("This item has no date to change.") + + End Select + + Return previous + + End Function + + + ' Done applies to your own reminders. Returns the reminder so a failed + ' save can reopen it. + Public Shared Function Complete(manuscript As Manuscript, item As DeadlineItem, asOfDate As DateTime) As ManuscriptReminder + + If manuscript Is Nothing Then Throw New ArgumentNullException(NameOf(manuscript)) + If item Is Nothing OrElse item.Kind <> DeadlineKind.Reminder Then Throw New InvalidOperationException("Only reminders are marked done.") + + Dim reminder As ManuscriptReminder = FindReminder(manuscript, item.ReminderId) + If reminder Is Nothing Then Throw New InvalidOperationException("This reminder no longer exists.") + + reminder.IsCompleted = True + reminder.CompletedDate = asOfDate.Date + Return reminder + + End Function + + + Private Shared Function FindSubmission(manuscript As Manuscript, submissionId As Guid?) As JournalSubmission + If Not submissionId.HasValue OrElse manuscript.Submissions Is Nothing Then Return Nothing + Return manuscript.Submissions.FirstOrDefault(Function(candidate) candidate IsNot Nothing AndAlso candidate.Id = submissionId.Value) + End Function + + + Private Shared Function FindDecision(manuscript As Manuscript, decisionId As Guid?) As EditorialDecisionEvent + If Not decisionId.HasValue OrElse manuscript.Submissions Is Nothing Then Return Nothing + Return manuscript.Submissions. + Where(Function(candidate) candidate IsNot Nothing AndAlso candidate.Decisions IsNot Nothing). + SelectMany(Function(candidate) candidate.Decisions). + FirstOrDefault(Function(candidate) candidate IsNot Nothing AndAlso candidate.Id = decisionId.Value) + End Function + + + Private Shared Function FindReminder(manuscript As Manuscript, reminderId As Guid?) As ManuscriptReminder + If Not reminderId.HasValue OrElse manuscript.Reminders Is Nothing Then Return Nothing + Return manuscript.Reminders.FirstOrDefault(Function(candidate) candidate IsNot Nothing AndAlso candidate.Id = reminderId.Value) + End Function + + + ' Addressed and not-applicable comments count as done. + Private Shared Sub SetCommentProgress(item As DeadlineItem, submission As JournalSubmission, decision As EditorialDecisionEvent) + + If submission Is Nothing OrElse decision Is Nothing OrElse submission.ReviewerResponses Is Nothing Then Return + + Dim comments As List(Of ReviewerResponseItem) = + submission.ReviewerResponses.Where(Function(comment) comment IsNot Nothing AndAlso comment.DecisionId = decision.Id).ToList() + + item.ProgressTotal = comments.Count + item.ProgressDone = comments.Where(Function(comment) comment.Status = ReviewerResponseStatus.Addressed OrElse + comment.Status = ReviewerResponseStatus.NotApplicable).Count() + item.ProgressActive = comments.Where(Function(comment) comment.Status = ReviewerResponseStatus.InProgress).Count() + + End Sub + + End Class + +End Namespace diff --git a/ManuscriptPipeline/Services/ManuscriptRepository.vb b/ManuscriptPipeline/Services/ManuscriptRepository.vb index 8d9b35f..28499d2 100644 --- a/ManuscriptPipeline/Services/ManuscriptRepository.vb +++ b/ManuscriptPipeline/Services/ManuscriptRepository.vb @@ -650,6 +650,7 @@ Namespace Services ) ReviewerResponseService.NormalizeAndValidateManuscript(manuscript) + PublicationMatchService.NormalizeAndValidateManuscript(manuscript) Next @@ -1224,6 +1225,7 @@ Namespace Services ) ReviewerResponseService.NormalizeAndValidateManuscript(manuscript) + PublicationMatchService.NormalizeAndValidateManuscript(manuscript) Next diff --git a/ManuscriptPipeline/Services/PortableRestoreService.vb b/ManuscriptPipeline/Services/PortableRestoreService.vb index 40eafaa..39ed098 100644 --- a/ManuscriptPipeline/Services/PortableRestoreService.vb +++ b/ManuscriptPipeline/Services/PortableRestoreService.vb @@ -1137,6 +1137,7 @@ Namespace Services ) ReviewerResponseService.NormalizeAndValidateManuscript(manuscript) + PublicationMatchService.NormalizeAndValidateManuscript(manuscript) Next diff --git a/ManuscriptPipeline/Services/PublicationCheckService.vb b/ManuscriptPipeline/Services/PublicationCheckService.vb new file mode 100644 index 0000000..0ed739b --- /dev/null +++ b/ManuscriptPipeline/Services/PublicationCheckService.vb @@ -0,0 +1,212 @@ +Imports System +Imports System.Collections.Generic +Imports System.Linq +Imports System.Net.Http +Imports System.Threading +Imports System.Threading.Tasks +Imports ManuscriptPipeline.Models + +Namespace Services + + ' Where a publication check looks. The app asks Crossref and ORCID; + ' tests answer with synthetic records. + Public Interface IPublicationSource + + ' Nothing when the DOI is unknown. + Function LookupDoiAsync(doi As String, cancellationToken As CancellationToken) As Task(Of CrossrefMetadataSuggestion) + + Function SearchTitleAsync(title As String, cancellationToken As CancellationToken) As Task(Of List(Of CrossrefMetadataSuggestion)) + + Function OrcidWorksAsync(orcid As String, cancellationToken As CancellationToken) As Task(Of List(Of OrcidWorkSuggestion)) + + End Interface + + + Public Class OnlinePublicationSource + Implements IPublicationSource + + Private ReadOnly _crossref As New CrossrefClient() + Private ReadOnly _orcid As New OrcidClient() + + Public Async Function LookupDoiAsync(doi As String, cancellationToken As CancellationToken) As Task(Of CrossrefMetadataSuggestion) Implements IPublicationSource.LookupDoiAsync + Try + Return Await _crossref.LookupAsync(doi, cancellationToken) + Catch ex As CrossrefNotFoundException + Return Nothing + End Try + End Function + + Public Function SearchTitleAsync(title As String, cancellationToken As CancellationToken) As Task(Of List(Of CrossrefMetadataSuggestion)) Implements IPublicationSource.SearchTitleAsync + Return _crossref.SearchByTitleAsync(title, cancellationToken) + End Function + + Public Async Function OrcidWorksAsync(orcid As String, cancellationToken As CancellationToken) As Task(Of List(Of OrcidWorkSuggestion)) Implements IPublicationSource.OrcidWorksAsync + Dim profile As OrcidProfileSuggestion = Await _orcid.LookupAsync(orcid, cancellationToken) + Return If(profile?.Works, New List(Of OrcidWorkSuggestion)()) + End Function + + End Class + + + Public Class PublicationCheckProgress + Public Property Index As Integer + Public Property Total As Integer + Public Property ManuscriptTitle As String = String.Empty + End Class + + + Public Class PublicationCheckResult + + ' New possible publications, not yet added to their manuscripts. + Public Property Matches As New List(Of (Manuscript As Manuscript, Match As PublicationMatch))() + + Public Property Checked As Integer + + ' Manuscripts that could not be checked, and why. + Public Property Failures As New List(Of (Manuscript As Manuscript, Reason As String))() + + ' Set when the check ended early: cancelled, or Crossref asked to slow down. + Public Property StoppedReason As String = String.Empty + + End Class + + + ' Runs a user-initiated publication check (#61). It asks one question at + ' a time, never changes a manuscript, and reports what it could not do. + Public NotInheritable Class PublicationCheckService + + Private Sub New() + End Sub + + + Public Shared Async Function CheckAsync( + manuscripts As IList(Of Manuscript), + orcid As String, + source As IPublicationSource, + Optional progress As IProgress(Of PublicationCheckProgress) = Nothing, + Optional pause As TimeSpan = Nothing, + Optional cancellationToken As CancellationToken = Nothing + ) As Task(Of PublicationCheckResult) + + If manuscripts Is Nothing Then Throw New ArgumentNullException(NameOf(manuscripts)) + If source Is Nothing Then Throw New ArgumentNullException(NameOf(source)) + + Dim result As New PublicationCheckResult() + Dim works As New List(Of OrcidWorkSuggestion)() + + Try + + If Not String.IsNullOrWhiteSpace(orcid) Then + Try + works = Await source.OrcidWorksAsync(OrcidIdentifierService.NormalizeAndValidate(orcid), cancellationToken) + Catch ex As Exception When IsLookupFailure(ex, cancellationToken) + result.Failures.Add((Nothing, "The ORCID record could not be read: " & ex.Message)) + End Try + End If + + For index As Integer = 0 To manuscripts.Count - 1 + + Dim manuscript As Manuscript = manuscripts(index) + If manuscript Is Nothing OrElse Not PublicationMatchService.IsEligible(manuscript) Then Continue For + + cancellationToken.ThrowIfCancellationRequested() + progress?.Report(New PublicationCheckProgress With {.Index = index + 1, .Total = manuscripts.Count, .ManuscriptTitle = manuscript.Title}) + + Try + Dim match As PublicationMatch = Await CheckOneAsync(manuscript, works, source, pause, cancellationToken) + If match IsNot Nothing Then result.Matches.Add((manuscript, match)) + result.Checked += 1 + Catch ex As CrossrefRateLimitException + Throw + Catch ex As Exception When IsLookupFailure(ex, cancellationToken) + result.Failures.Add((manuscript, ex.Message)) + End Try + + Next + + Catch ex As CrossrefRateLimitException + result.StoppedReason = ex.Message + Catch ex As OperationCanceledException When cancellationToken.IsCancellationRequested + result.StoppedReason = "The check was cancelled." + End Try + + Return result + + End Function + + + ' The manuscript's own DOIs first, then its title, then the ORCID works. + Private Shared Async Function CheckOneAsync( + manuscript As Manuscript, + works As List(Of OrcidWorkSuggestion), + source As IPublicationSource, + pause As TimeSpan, + cancellationToken As CancellationToken + ) As Task(Of PublicationMatch) + + Dim metadata As ManuscriptMetadata = If(manuscript.Metadata, New ManuscriptMetadata()) + + For Each candidate In {(metadata.Doi, True), (metadata.PreprintDoi, False)} + + Dim doi As String = DoiNormalizer.Normalize(If(candidate.Item1, String.Empty)) + If Not DoiNormalizer.IsValid(doi) Then Continue For + + Dim work As CrossrefMetadataSuggestion = Await source.LookupDoiAsync(doi, cancellationToken) + Await Wait(pause, cancellationToken) + If work Is Nothing Then Continue For + + For Each published As String In If(work.PublishedVersionDois, New List(Of String)()) + Dim version As CrossrefMetadataSuggestion = Await source.LookupDoiAsync(published, cancellationToken) + Await Wait(pause, cancellationToken) + Dim match As PublicationMatch = PublicationMatchService.Consider(manuscript, version, PublicationMatchSource.Preprint) + If match IsNot Nothing Then Return match + Next + + If candidate.Item2 Then + Dim match As PublicationMatch = PublicationMatchService.Consider(manuscript, work, PublicationMatchSource.Doi) + If match IsNot Nothing Then Return match + End If + + Next + + If PublicationMatchService.ContentWords(manuscript.Title).Count >= PublicationMatchService.MinimumTitleWords Then + Dim found As List(Of CrossrefMetadataSuggestion) = Await source.SearchTitleAsync(manuscript.Title, cancellationToken) + Await Wait(pause, cancellationToken) + Dim best As PublicationMatch = BestByTitle(manuscript, found, PublicationMatchSource.Title) + If best IsNot Nothing Then Return best + End If + + Return BestByTitle(manuscript, works.Select(Function(work) PublicationMatchService.FromOrcidWork(work)), PublicationMatchSource.Orcid) + + End Function + + + Private Shared Function BestByTitle(manuscript As Manuscript, works As IEnumerable(Of CrossrefMetadataSuggestion), source As PublicationMatchSource) As PublicationMatch + Return If(works, Enumerable.Empty(Of CrossrefMetadataSuggestion)()). + Where(Function(work) work IsNot Nothing). + OrderByDescending(Function(work) PublicationMatchService.TitleSimilarity(manuscript.Title, work.Title)). + Select(Function(work) PublicationMatchService.Consider(manuscript, work, source)). + FirstOrDefault(Function(match) match IsNot Nothing) + End Function + + + ' A pause between requests keeps a large check polite to Crossref. + Private Shared Async Function Wait(pause As TimeSpan, cancellationToken As CancellationToken) As Task + If pause > TimeSpan.Zero Then Await Task.Delay(pause, cancellationToken) + End Function + + + ' Network trouble and unexpected responses affect one manuscript; + ' the user's Cancel does not count. + Private Shared Function IsLookupFailure(ex As Exception, cancellationToken As CancellationToken) As Boolean + If TypeOf ex Is CrossrefRateLimitException Then Return False + If TypeOf ex Is OperationCanceledException Then Return Not cancellationToken.IsCancellationRequested + Return TypeOf ex Is HttpRequestException OrElse + TypeOf ex Is InvalidOperationException OrElse + TypeOf ex Is ArgumentException OrElse + TypeOf ex Is System.Text.Json.JsonException + End Function + + End Class + +End Namespace diff --git a/ManuscriptPipeline/Services/PublicationMatchService.vb b/ManuscriptPipeline/Services/PublicationMatchService.vb new file mode 100644 index 0000000..c6b37c3 --- /dev/null +++ b/ManuscriptPipeline/Services/PublicationMatchService.vb @@ -0,0 +1,430 @@ +Imports System +Imports System.Collections.Generic +Imports System.Globalization +Imports System.IO +Imports System.Linq +Imports System.Text +Imports ManuscriptPipeline.Models + +Namespace Services + + ' The rules of the publication check (#61): which manuscripts to check, + ' what counts as a possible publication, and what Mark Published and + ' Fill Blanks change. Nothing here touches the network. + Public NotInheritable Class PublicationMatchService + + ' Titles this similar (Dice coefficient over their meaningful words) + ' are the same work. + Public Const TitleThreshold As Double = 0.85 + + ' Shorter titles, such as "Introduction", are too common to match. + Public Const MinimumTitleWords As Integer = 4 + + ' A title whose words all appear in the other, such as a working + ' title that gained a subtitle, matches when it has at least this many. + Public Const ContainedTitleWords As Integer = 5 + + Private Shared ReadOnly StopWords As New HashSet(Of String)( + {"a", "an", "the", "of", "in", "on", "for", "and", "or", "to", "with", "at", "by", "from", "as", "is", "are"}) + + ' A publication earlier than the first submission, less this margin, + ' is an older work that shares the title. + Public Const SubmissionMarginDays As Integer = 30 + + Private Shared ReadOnly NotPublications As String() = { + "posted-content", "preprint", "peer-review", "dataset", "component", "working-paper", "grant" + } + + Private Sub New() + End Sub + + + Public Shared Sub NormalizeAndValidateManuscript(manuscript As Manuscript) + + If manuscript Is Nothing Then Throw New ArgumentNullException(NameOf(manuscript)) + If manuscript.PublicationMatches Is Nothing Then manuscript.PublicationMatches = New List(Of PublicationMatch)() + + Dim ids As New HashSet(Of Guid)() + + For Each match As PublicationMatch In manuscript.PublicationMatches + If match Is Nothing Then Throw New InvalidDataException("The manuscript library contains a null publication match.") + If match.Id = Guid.Empty OrElse Not ids.Add(match.Id) Then + Throw New InvalidDataException("The manuscript library contains an invalid or duplicate publication match identifier.") + End If + If Not [Enum].IsDefined(match.Source) OrElse Not [Enum].IsDefined(match.Status) Then + Throw New InvalidDataException("The manuscript library contains a publication match with an unsupported source or status.") + End If + match.Doi = If(match.Doi, String.Empty) + match.Title = If(match.Title, String.Empty) + match.Journal = If(match.Journal, String.Empty) + match.Url = If(match.Url, String.Empty) + match.Publisher = If(match.Publisher, String.Empty) + match.Volume = If(match.Volume, String.Empty) + match.Issue = If(match.Issue, String.Empty) + match.Pages = If(match.Pages, String.Empty) + Next + + End Sub + + + ' Anything not yet published can be checked. + Public Shared Function IsEligible(manuscript As Manuscript) As Boolean + Return manuscript IsNot Nothing AndAlso + manuscript.Location <> ManuscriptLocation.Published AndAlso + manuscript.CurrentStage <> PaperStage.Published + End Function + + + ' Checked by default: work that has gone to a journal. + Public Shared Function IsSuggested(manuscript As Manuscript) As Boolean + Return IsEligible(manuscript) AndAlso + manuscript.Location = ManuscriptLocation.Pipeline AndAlso + manuscript.CurrentStage >= PaperStage.Submitted + End Function + + + ' Lowercase words without accents or punctuation. + Public Shared Function TitleWords(title As String) As List(Of String) + + Dim words As New List(Of String)() + If String.IsNullOrWhiteSpace(title) Then Return words + + Dim decomposed As String = title.Normalize(NormalizationForm.FormD) + Dim current As New StringBuilder() + + For Each character As Char In decomposed + Select Case CharUnicodeInfo.GetUnicodeCategory(character) + Case UnicodeCategory.NonSpacingMark + Continue For + Case UnicodeCategory.LowercaseLetter, UnicodeCategory.UppercaseLetter, UnicodeCategory.TitlecaseLetter, + UnicodeCategory.OtherLetter, UnicodeCategory.DecimalDigitNumber + current.Append(Char.ToLowerInvariant(character)) + Case Else + If current.Length > 0 Then words.Add(current.ToString()) : current.Clear() + End Select + Next + + If current.Length > 0 Then words.Add(current.ToString()) + Return words + + End Function + + + ' The words that distinguish a title: no articles or prepositions. + Public Shared Function ContentWords(title As String) As HashSet(Of String) + Return New HashSet(Of String)(TitleWords(title).Where(Function(word) Not StopWords.Contains(word))) + End Function + + + ' 1 for the same words in any case or punctuation; 0 for none shared. + Public Shared Function TitleSimilarity(first As String, second As String) As Double + + Dim a As HashSet(Of String) = ContentWords(first) + Dim b As HashSet(Of String) = ContentWords(second) + If a.Count = 0 OrElse b.Count = 0 Then Return 0 + + Dim common As Integer = a.Where(Function(word) b.Contains(word)).Count() + Return 2.0 * common / (a.Count + b.Count) + + End Function + + + Public Shared Function IsSameTitle(first As String, second As String) As Boolean + + If TitleSimilarity(first, second) >= TitleThreshold Then Return True + + Dim a As HashSet(Of String) = ContentWords(first) + Dim b As HashSet(Of String) = ContentWords(second) + Dim smaller As HashSet(Of String) = If(a.Count <= b.Count, a, b) + Dim larger As HashSet(Of String) = If(a.Count <= b.Count, b, a) + Return smaller.Count >= ContainedTitleWords AndAlso smaller.IsSubsetOf(larger) + + End Function + + + Public Shared Function IsPublicationType(workType As String) As Boolean + Return Not NotPublications.Contains(If(workType, String.Empty).Trim(), StringComparer.OrdinalIgnoreCase) + End Function + + + ' Found works are remembered by DOI, or by title when there is none. + Public Shared Function MatchKey(doi As String, title As String) As String + Dim normalized As String = DoiNormalizer.Normalize(If(doi, String.Empty)) + If DoiNormalizer.IsValid(normalized) Then Return "doi:" & normalized.ToLowerInvariant() + Return "title:" & String.Join(" ", TitleWords(title)) + End Function + + + Public Shared Function EarliestPlausibleDate(manuscript As Manuscript) As DateTime? + + If manuscript.Submissions Is Nothing Then Return Nothing + + Dim dates As List(Of DateTime) = + manuscript.Submissions.Where(Function(submission) submission IsNot Nothing). + Select(Function(submission) submission.SubmittedDate.Date).ToList() + + If dates.Count = 0 Then Return Nothing + Return dates.Min().AddDays(-SubmissionMarginDays) + + End Function + + + ' Returns a new match when a found work qualifies and was not found + ' before, whatever the earlier answer was; otherwise Nothing. + Public Shared Function Consider( + manuscript As Manuscript, + work As CrossrefMetadataSuggestion, + source As PublicationMatchSource + ) As PublicationMatch + + If manuscript Is Nothing Then Throw New ArgumentNullException(NameOf(manuscript)) + If work Is Nothing Then Return Nothing + + If Not IsPublicationType(work.WorkType) Then Return Nothing + + Dim doi As String = DoiNormalizer.Normalize(If(work.Doi, String.Empty)) + Dim metadata As ManuscriptMetadata = If(manuscript.Metadata, New ManuscriptMetadata()) + + If DoiNormalizer.IsValid(doi) AndAlso + String.Equals(doi, DoiNormalizer.Normalize(If(metadata.PreprintDoi, String.Empty)), StringComparison.OrdinalIgnoreCase) Then + Return Nothing + End If + + Select Case source + + Case PublicationMatchSource.Doi, PublicationMatchSource.Preprint + ' A DOI names the work. It is a publication once it has a venue. + If String.IsNullOrWhiteSpace(work.Journal) Then Return Nothing + + Case Else + If ContentWords(manuscript.Title).Count < MinimumTitleWords Then Return Nothing + If Not IsSameTitle(manuscript.Title, work.Title) Then Return Nothing + Dim earliest As DateTime? = EarliestPlausibleDate(manuscript) + If earliest.HasValue AndAlso work.PublishedDate.HasValue AndAlso work.PublishedDate.Value.Date < earliest.Value Then + Return Nothing + End If + + End Select + + Dim key As String = MatchKey(doi, work.Title) + If manuscript.PublicationMatches IsNot Nothing AndAlso + manuscript.PublicationMatches.Any(Function(existing) existing IsNot Nothing AndAlso MatchKey(existing.Doi, existing.Title) = key) Then + Return Nothing + End If + + Return New PublicationMatch With { + .Doi = If(DoiNormalizer.IsValid(doi), doi, String.Empty), + .Title = If(work.Title, String.Empty).Trim(), + .Journal = If(work.Journal, String.Empty).Trim(), + .PublishedDate = If(work.PublishedDate.HasValue, work.PublishedDate.Value.Date, CType(Nothing, DateTime?)), + .Url = If(work.Url, String.Empty).Trim(), + .Publisher = If(work.Publisher, String.Empty).Trim(), + .Volume = If(work.Volume, String.Empty).Trim(), + .Issue = If(work.Issue, String.Empty).Trim(), + .Pages = If(work.Pages, String.Empty).Trim(), + .Source = source + } + + End Function + + + ' An ORCID work, in the shape Crossref results use. + Public Shared Function FromOrcidWork(work As OrcidWorkSuggestion) As CrossrefMetadataSuggestion + If work Is Nothing Then Return Nothing + Return New CrossrefMetadataSuggestion With { + .Doi = If(work.Doi, String.Empty), + .Title = If(work.Title, String.Empty), + .Journal = If(work.JournalTitle, String.Empty), + .PublishedDate = work.PublishedDate, + .WorkType = If(work.WorkType, String.Empty) + } + End Function + + + Public Shared Function PendingMatches(manuscript As Manuscript) As List(Of PublicationMatch) + If manuscript Is Nothing OrElse manuscript.PublicationMatches Is Nothing OrElse Not IsEligible(manuscript) Then + Return New List(Of PublicationMatch)() + End If + Return manuscript.PublicationMatches. + Where(Function(match) match IsNot Nothing AndAlso match.Status = PublicationMatchStatus.Pending).ToList() + End Function + + + Public Shared Sub Ignore(match As PublicationMatch, Optional reviewedAtUtc As DateTime? = Nothing) + If match Is Nothing Then Throw New ArgumentNullException(NameOf(match)) + match.Status = PublicationMatchStatus.Ignored + match.ReviewedAtUtc = If(reviewedAtUtc, DateTime.UtcNow) + End Sub + + + ' What Mark Published will change, in words, for the confirmation. + Public Shared Function DescribeMarkPublished(manuscript As Manuscript, match As PublicationMatch, today As DateTime) As List(Of String) + + Dim lines As New List(Of String) From { + "Move it to the Published shelf with the stage Published, from " & + PublishedFrom(match, today).ToString("MMMM d, yyyy", CultureInfo.CurrentCulture) & ".", + "Add """ & HistoryNote(match) & """ to its history." + } + + If Not String.IsNullOrWhiteSpace(match.Journal) AndAlso + Not String.Equals(If(manuscript.TargetJournal, String.Empty).Trim(), match.Journal.Trim(), StringComparison.CurrentCultureIgnoreCase) Then + lines.Add(If(String.IsNullOrWhiteSpace(manuscript.TargetJournal), + "Set the journal to " & match.Journal & ".", + "Change the journal from " & manuscript.TargetJournal.Trim() & " to " & match.Journal & ".")) + End If + + Dim fills As List(Of String) = PlanFill(manuscript, ToWork(match)).Select(Function(fill) fill.Field).ToList() + If fills.Count > 0 Then + lines.Add("Fill empty fields: " & String.Join(", ", fills) & ". Fields that already have a value are kept.") + End If + + Return lines + + End Function + + + ' The explicit choice that changes stage, shelf, journal, and + ' history. Earlier submissions and decisions are left as recorded. + Public Shared Sub MarkPublished(manuscript As Manuscript, match As PublicationMatch, today As DateTime) + + If manuscript Is Nothing Then Throw New ArgumentNullException(NameOf(manuscript)) + If match Is Nothing Then Throw New ArgumentNullException(NameOf(match)) + + Dim publishedOn As DateTime = PublishedFrom(match, today) + + ApplyFill(manuscript, PlanFill(manuscript, ToWork(match))) + + If Not String.IsNullOrWhiteSpace(match.Journal) AndAlso + Not String.Equals(If(manuscript.TargetJournal, String.Empty).Trim(), match.Journal.Trim(), StringComparison.CurrentCultureIgnoreCase) Then + manuscript.TargetJournal = match.Journal.Trim() + manuscript.TargetJournalId = Nothing + End If + + manuscript.CurrentStage = PaperStage.Published + manuscript.Location = ManuscriptLocation.Published + manuscript.StageEnteredDate = publishedOn + manuscript.FileDrawerDate = Nothing + manuscript.FileDrawerReason = String.Empty + + Dim published As New HistoryEvent With { + .EventDate = publishedOn, + .Stage = PaperStage.Published, + .Note = HistoryNote(match) + } + ChronologyProvenanceService.StampCreated(published) + If manuscript.History Is Nothing Then manuscript.History = New List(Of HistoryEvent)() + manuscript.History.Add(published) + + match.Status = PublicationMatchStatus.Confirmed + match.ReviewedAtUtc = DateTime.UtcNow + + ' Other possible matches no longer need review. + For Each other As PublicationMatch In manuscript.PublicationMatches.Where(Function(item) item IsNot Nothing AndAlso item IsNot match AndAlso item.Status = PublicationMatchStatus.Pending) + other.Status = PublicationMatchStatus.Ignored + other.ReviewedAtUtc = match.ReviewedAtUtc + Next + + End Sub + + + Private Shared Function PublishedFrom(match As PublicationMatch, today As DateTime) As DateTime + If match.PublishedDate.HasValue AndAlso match.PublishedDate.Value.Date <= today.Date Then Return match.PublishedDate.Value.Date + Return today.Date + End Function + + + Private Shared Function HistoryNote(match As PublicationMatch) As String + Dim note As String = "Published" & If(String.IsNullOrWhiteSpace(match.Journal), String.Empty, " in " & match.Journal.Trim()) + If Not String.IsNullOrWhiteSpace(match.Doi) Then note &= " (DOI " & match.Doi & ")" + Return note & ". Marked from a publication check." + End Function + + + Private Shared Function ToWork(match As PublicationMatch) As CrossrefMetadataSuggestion + Return New CrossrefMetadataSuggestion With { + .Doi = match.Doi, + .Title = match.Title, + .Journal = match.Journal, + .PublishedDate = match.PublishedDate, + .Url = match.Url, + .Publisher = match.Publisher, + .Volume = match.Volume, + .Issue = match.Issue, + .Pages = match.Pages + } + End Function + + + ' Empty fields a Crossref record can fill. A field with a value, even + ' a different one, is never in the plan. + Public Shared Function PlanFill(manuscript As Manuscript, work As CrossrefMetadataSuggestion) As List(Of MetadataFill) + + If manuscript Is Nothing Then Throw New ArgumentNullException(NameOf(manuscript)) + Dim plan As New List(Of MetadataFill)() + If work Is Nothing Then Return plan + + Dim metadata As ManuscriptMetadata = If(manuscript.Metadata, New ManuscriptMetadata()) + + Dim add As Action(Of String, String, String, Action(Of ManuscriptMetadata)) = + Sub(field, current, value, apply) + If String.IsNullOrWhiteSpace(current) AndAlso Not String.IsNullOrWhiteSpace(value) Then + plan.Add(New MetadataFill With {.Manuscript = manuscript, .Field = field, .Value = value.Trim(), .Apply = apply}) + End If + End Sub + + Dim doi As String = DoiNormalizer.Normalize(If(work.Doi, String.Empty)) + If DoiNormalizer.IsValid(doi) Then add("DOI", metadata.Doi, doi, Sub(target) target.Doi = doi) + add("Journal", metadata.PublicationJournal, work.Journal, Sub(target) target.PublicationJournal = work.Journal.Trim()) + add("Publisher", metadata.Publisher, work.Publisher, Sub(target) target.Publisher = work.Publisher.Trim()) + If Not metadata.PublishedDate.HasValue AndAlso work.PublishedDate.HasValue Then + Dim published As DateTime = work.PublishedDate.Value.Date + plan.Add(New MetadataFill With { + .Manuscript = manuscript, .Field = "Publication date", + .Value = published.ToString("MMMM d, yyyy", CultureInfo.CurrentCulture), + .Apply = Sub(target) target.PublishedDate = published}) + End If + add("Volume", metadata.Volume, work.Volume, Sub(target) target.Volume = work.Volume.Trim()) + add("Issue", metadata.Issue, work.Issue, Sub(target) target.Issue = work.Issue.Trim()) + add("Pages", metadata.Pages, work.Pages, Sub(target) target.Pages = work.Pages.Trim()) + add("Publication URL", metadata.PublicationUrl, work.Url, Sub(target) target.PublicationUrl = work.Url.Trim()) + add("Abstract", metadata.AbstractText, work.AbstractText, Sub(target) target.AbstractText = work.AbstractText.Trim()) + + Dim keywords As List(Of String) = If(work.Keywords, New List(Of String)()). + Where(Function(keyword) Not String.IsNullOrWhiteSpace(keyword)).Select(Function(keyword) keyword.Trim()). + Distinct(StringComparer.OrdinalIgnoreCase).ToList() + If (metadata.Keywords Is Nothing OrElse metadata.Keywords.Count = 0) AndAlso keywords.Count > 0 Then + plan.Add(New MetadataFill With { + .Manuscript = manuscript, .Field = "Keywords", .Value = String.Join("; ", keywords), + .Apply = Sub(target) target.Keywords = keywords.ToList()}) + End If + + Return plan + + End Function + + + Public Shared Sub ApplyFill(manuscript As Manuscript, fills As IEnumerable(Of MetadataFill)) + If manuscript Is Nothing Then Throw New ArgumentNullException(NameOf(manuscript)) + If manuscript.Metadata Is Nothing Then manuscript.Metadata = New ManuscriptMetadata() + For Each fill As MetadataFill In fills + fill.Apply.Invoke(manuscript.Metadata) + Next + End Sub + + End Class + + + ' One empty field and the value that would fill it. + Public Class MetadataFill + + Public Property Manuscript As Manuscript + + Public Property Field As String = String.Empty + + Public Property Value As String = String.Empty + + Friend Property Apply As Action(Of ManuscriptMetadata) + + End Class + +End Namespace diff --git a/ManuscriptPipeline/Services/ReminderService.vb b/ManuscriptPipeline/Services/ReminderService.vb index 946ab30..6eb124c 100644 --- a/ManuscriptPipeline/Services/ReminderService.vb +++ b/ManuscriptPipeline/Services/ReminderService.vb @@ -188,7 +188,7 @@ Namespace Services ", ", parts ) & - ". Open Reminders in PaperRoute for details." + ". Open Deadlines in PaperRoute for details." End Function @@ -270,6 +270,11 @@ Namespace Services .Title = "Revision deadline", .Notes = "Revision deadline recorded in the manuscript's editorial history.", + .JournalName = + JournalFor( + manuscript, + latestSubmission + ), .SubmissionId = submissionId, .IsEditableReminder = False @@ -426,7 +431,35 @@ Namespace Services End Function - Private Shared Function SafeManuscriptTitle( + ' The journal handling the latest submission, or the target journal + ' when nothing has been submitted yet. + Friend Shared Function JournalFor( + manuscript As Manuscript, + submission As JournalSubmission + ) As String + + If submission IsNot Nothing AndAlso + Not String.IsNullOrWhiteSpace( + submission.JournalName + ) Then + + Return submission.JournalName.Trim() + + End If + + If manuscript Is Nothing Then + Return String.Empty + End If + + Return If( + manuscript.TargetJournal, + String.Empty + ).Trim() + + End Function + + + Friend Shared Function SafeManuscriptTitle( manuscript As Manuscript ) As String diff --git a/ManuscriptPipeline/Services/Schema7MigrationService.vb b/ManuscriptPipeline/Services/Schema7MigrationService.vb new file mode 100644 index 0000000..b3828d3 --- /dev/null +++ b/ManuscriptPipeline/Services/Schema7MigrationService.vb @@ -0,0 +1,64 @@ +Imports System +Imports System.Collections.Generic +Imports System.IO +Imports System.Text.Json +Imports System.Text.Json.Serialization +Imports ManuscriptPipeline.Models + +Namespace Services + + Friend NotInheritable Class Schema7MigrationService + Private Sub New() + End Sub + + Friend Shared Sub Migrate(currentRoot As String, schemaPath As String) + ' Schema 7 adds remembered publication matches (#61), which default + ' to empty. As in Schema 6, the older JSON, managed files, and + ' backups stay byte-for-byte; only the marker advances, after all + ' existing data validates. The marker keeps an older build from + ' opening the library and silently dropping ignored matches. + Dim options As New JsonSerializerOptions With {.PropertyNameCaseInsensitive = True} + options.Converters.Add(New JsonStringEnumConverter()) + Try + Dim manuscriptPath = Path.Combine(currentRoot, "data", "manuscripts.json") + If File.Exists(manuscriptPath) Then + Dim manuscripts = JsonSerializer.Deserialize(Of List(Of Manuscript))(File.ReadAllText(manuscriptPath), options) + If manuscripts Is Nothing Then Throw New InvalidDataException("The manuscript library cannot be null.") + For Each manuscript In manuscripts + If manuscript Is Nothing Then Throw New InvalidDataException("The manuscript library contains a null record.") + SubmissionReadinessValidationService.NormalizeAndValidateManuscript(manuscript) + ReviewerResponseService.NormalizeAndValidateManuscript(manuscript) + PublicationMatchService.NormalizeAndValidateManuscript(manuscript) + Next + End If + Dim authorsPath = Path.Combine(currentRoot, "data", "authors.json") + If File.Exists(authorsPath) Then + Dim library = JsonSerializer.Deserialize(Of AuthorLibraryData)(File.ReadAllText(authorsPath), options) + If library Is Nothing Then Throw New InvalidDataException("The reusable metadata library cannot be null.") + End If + Catch ex As Exception When TypeOf ex Is JsonException OrElse TypeOf ex Is InvalidDataException + Throw New InvalidDataException("PaperRoute cannot migrate storage schema 6 because existing data is invalid. The existing schema and data were left unchanged. " & ex.Message, ex) + End Try + + Dim payload As New Dictionary(Of String, Object) From { + {"SchemaVersion", 7}, {"UpdatedAtUtc", DateTime.UtcNow.ToString("O")} + } + Dim temporaryPath = schemaPath & ".tmp-" & Guid.NewGuid().ToString("N") + Try + File.WriteAllText(temporaryPath, JsonSerializer.Serialize(payload, New JsonSerializerOptions With {.WriteIndented = True})) + ' Replacing atomically also preserves any existing backup if + ' the marker is locked. Never remove that backup beforehand. + File.Replace(temporaryPath, schemaPath, Path.Combine(Path.GetDirectoryName(schemaPath), "schema.v6.bak"), True) + Finally + If File.Exists(temporaryPath) Then + Try + File.Delete(temporaryPath) + Catch + ' Best-effort temporary-file cleanup only. + End Try + End If + End Try + End Sub + End Class + +End Namespace diff --git a/ManuscriptPipeline/Services/StorageMigrationService.vb b/ManuscriptPipeline/Services/StorageMigrationService.vb index 3b29161..9f57497 100644 --- a/ManuscriptPipeline/Services/StorageMigrationService.vb +++ b/ManuscriptPipeline/Services/StorageMigrationService.vb @@ -10,7 +10,7 @@ Namespace Services Public NotInheritable Class StorageMigrationService - Public Const CurrentSchemaVersion As Integer = 6 + Public Const CurrentSchemaVersion As Integer = 7 Private Const MinimumMigratableSchemaVersion As Integer = 1 @@ -370,6 +370,10 @@ Namespace Services Schema6MigrationService.Migrate(currentRoot, schemaPath) + Case 6 + + Schema7MigrationService.Migrate(currentRoot, schemaPath) + Case Else Throw New InvalidOperationException( diff --git a/ManuscriptPipeline/Services/UserGuideService.vb b/ManuscriptPipeline/Services/UserGuideService.vb index e36d0c4..7a3f557 100644 --- a/ManuscriptPipeline/Services/UserGuideService.vb +++ b/ManuscriptPipeline/Services/UserGuideService.vb @@ -302,7 +302,7 @@ Namespace Services "1. Add a manuscript from the PaperRoute board.", "2. Open a manuscript from its card to add authors, target journal, metadata, submissions, decisions, and files.", "3. Use Data for import/export, reusable authors and journals, bibliography interchange, CV exports, backup, and restore.", - "4. Open Reminders in the left rail for revision deadlines, journal follow-ups, custom reminders, and calendar export.", + "4. Open Deadlines in the left rail (Ctrl+4) for revision deadlines, journal follow-ups, your reminders, and calendar export.", "5. Use Settings > Backup Library before major changes or moving PaperRoute to another computer.", String.Empty, "The complete guide is also available on the PaperRoute GitHub repository." diff --git a/PaperRoute.Tests/DashboardShelfLayoutTests.vb b/PaperRoute.Tests/DashboardShelfLayoutTests.vb index c645a8f..3c851ff 100644 --- a/PaperRoute.Tests/DashboardShelfLayoutTests.vb +++ b/PaperRoute.Tests/DashboardShelfLayoutTests.vb @@ -341,7 +341,7 @@ Public Class DashboardShelfLayoutTests Application.DoEvents() CollectionAssert.AreEqual( - {"Board", "Library", "Journals", "Reminders", "Import & Export"}, + {"Board", "Library", "Journals", "Deadlines", "Import & Export"}, board.RailPages.Select(Function(page) page.Text).ToList()) Assert.AreEqual("Board", board.ActivePageName) Assert.IsFalse(Descendants(board).OfType(Of Button)().Any(Function(button) button.Text.StartsWith("Data")), diff --git a/PaperRoute.Tests/DeadlineServiceTests.vb b/PaperRoute.Tests/DeadlineServiceTests.vb new file mode 100644 index 0000000..9880721 --- /dev/null +++ b/PaperRoute.Tests/DeadlineServiceTests.vb @@ -0,0 +1,245 @@ +Imports System +Imports System.Collections.Generic +Imports System.Linq +Imports System.Text.Json +Imports Microsoft.VisualStudio.TestTools.UnitTesting +Imports ManuscriptPipeline.Models +Imports ManuscriptPipeline.Services + +' The Deadlines page's items (#28): dated items from the one reminder +' engine, undated obligations, and recently completed reminders. + +Public Class DeadlineServiceTests + + Private Shared ReadOnly Today As New DateTime(2026, 9, 27) + + + + + + + + Public Sub GroupsFollowTheDateCountedFromToday(days As Integer, expected As DeadlineGroup) + Assert.AreEqual(expected, DeadlineService.GroupFor(Today.AddDays(days), Today)) + Assert.AreEqual(expected, DeadlineService.GroupFor(Today.AddDays(days).AddHours(23), Today.AddHours(1)), "Times of day do not matter.") + End Sub + + + Public Sub RevisionShowsProgressFromTheCommentsOnItsDecision() + Dim manuscript As Manuscript = Revision("Anchoring replication", Today.AddDays(5)) + Dim submission As JournalSubmission = manuscript.Submissions.Single() + Dim decision As EditorialDecisionEvent = submission.Decisions.Single() + For Each status In {ReviewerResponseStatus.Addressed, ReviewerResponseStatus.Addressed, ReviewerResponseStatus.NotApplicable, + ReviewerResponseStatus.InProgress, ReviewerResponseStatus.Unresolved} + submission.ReviewerResponses.Add(New ReviewerResponseItem With {.DecisionId = decision.Id, .RevisionRoundNumber = 1, .Status = status}) + Next + ' A comment on an earlier decision is not part of this revision. + submission.ReviewerResponses.Add(New ReviewerResponseItem With {.DecisionId = Guid.NewGuid(), .Status = ReviewerResponseStatus.Unresolved}) + + Dim item As DeadlineItem = DeadlineService.Build({manuscript}, Today).Single() + + Assert.AreEqual(DeadlineKind.Revision, item.Kind) + Assert.AreEqual(DeadlineGroup.Next7Days, item.Group) + Assert.AreEqual(Today.AddDays(5), item.DueDate) + Assert.AreEqual(decision.Id, item.DecisionId) + Assert.AreEqual(submission.Id, item.SubmissionId) + Assert.AreEqual("Collabra: Psychology", item.JournalName, "Revision deadlines name their journal.") + Assert.AreEqual(3, item.ProgressDone) + Assert.AreEqual(1, item.ProgressActive) + Assert.AreEqual(5, item.ProgressTotal) + End Sub + + + Public Sub RevisionWithoutADeadlineIsListedOnceWithNoDate() + Dim undated As Manuscript = Revision("Sleep and emotion", Nothing) + Dim submission As JournalSubmission = undated.Submissions.Single() + submission.ReviewerResponses.Add(New ReviewerResponseItem With {.DecisionId = submission.Decisions.Single().Id, .Status = ReviewerResponseStatus.Addressed}) + submission.ReviewerResponses.Add(New ReviewerResponseItem With {.DecisionId = submission.Decisions.Single().Id, .Status = ReviewerResponseStatus.Unresolved}) + Dim dated As Manuscript = Revision("Anchoring replication", Today.AddDays(5)) + + Dim items As List(Of DeadlineItem) = DeadlineService.Build({undated, dated}, Today) + + Assert.AreEqual(2, items.Count, "Each revision appears once.") + Dim noDate As DeadlineItem = items.Single(Function(item) item.ManuscriptId = undated.Id) + Assert.AreEqual(DeadlineGroup.NoDate, noDate.Group) + Assert.IsNull(noDate.DueDate, "No date is invented.") + Assert.AreEqual("Revision has no deadline", noDate.Title) + Assert.AreEqual(1, noDate.ProgressDone) + Assert.AreEqual(2, noDate.ProgressTotal) + Assert.AreEqual(DeadlineGroup.Next7Days, items.Single(Function(item) item.ManuscriptId = dated.Id).Group) + End Sub + + + Public Sub UnsubmittedPacketWithOpenChecklistItemsIsPreparationWork() + Dim manuscript As New Manuscript With {.Title = "Teaching open science", .CurrentStage = PaperStage.Draft, .Location = ManuscriptLocation.Pipeline} + Dim profile As New ManuscriptReadiness With {.JournalName = "Nurse Education Today"} + profile.Items.Add(New ReadinessItemState With {.Title = "Cover letter", .IsRequired = True, .Status = ReadinessItemStatus.Complete}) + profile.Items.Add(New ReadinessItemState With {.Title = "Ethics statement", .IsRequired = True, .Status = ReadinessItemStatus.Unresolved}) + profile.Items.Add(New ReadinessItemState With {.Title = "Data statement", .IsRequired = True, .Status = ReadinessItemStatus.Unresolved}) + profile.Items.Add(New ReadinessItemState With {.Title = "Optional graphical abstract", .IsRequired = False, .Status = ReadinessItemStatus.Unresolved}) + manuscript.ReadinessProfiles.Add(profile) + Dim prepared As New SubmissionPacket With {.ReadinessProfileId = profile.Id, .JournalName = "Nurse Education Today"} + Dim submitted As New SubmissionPacket With {.ReadinessProfileId = profile.Id, .SubmissionId = Guid.NewGuid()} + manuscript.SubmissionPackets.Add(prepared) + manuscript.SubmissionPackets.Add(submitted) + + Dim item As DeadlineItem = DeadlineService.Build({manuscript}, Today).Single() + + Assert.AreEqual(DeadlineKind.Preparation, item.Kind) + Assert.AreEqual(DeadlineGroup.NoDate, item.Group) + Assert.AreEqual(prepared.Id, item.PacketId, "A packet already linked to a submission is not preparation work.") + Assert.AreEqual("Packet for Nurse Education Today: 2 checklist items open", item.Title) + Assert.AreEqual(1, item.ProgressDone) + Assert.AreEqual(3, item.ProgressTotal, "Only required items count.") + + manuscript.CurrentStage = PaperStage.Accepted + Assert.AreEqual(0, DeadlineService.Build({manuscript}, Today).Count, "Accepted work needs no preparation.") + manuscript.CurrentStage = PaperStage.Draft + manuscript.Location = ManuscriptLocation.FileDrawer + Assert.AreEqual(0, DeadlineService.Build({manuscript}, Today).Count, "File Drawer work is set aside.") + manuscript.Location = ManuscriptLocation.Pipeline + For Each state As ReadinessItemState In profile.Items + state.Status = ReadinessItemStatus.NotApplicable + Next + Assert.AreEqual(0, DeadlineService.Build({manuscript}, Today).Count, "A resolved checklist leaves nothing to do.") + End Sub + + + Public Sub CompletedRemindersStayInDoneForThirtyDays() + Dim manuscript As New Manuscript With {.Title = "Registered report"} + manuscript.Reminders.Add(New ManuscriptReminder With {.Title = "Open", .DueDate = Today.AddDays(2)}) + manuscript.Reminders.Add(New ManuscriptReminder With {.Title = "Recent", .DueDate = Today.AddDays(-5), .IsCompleted = True, .CompletedDate = Today.AddDays(-2)}) + manuscript.Reminders.Add(New ManuscriptReminder With {.Title = "Edge", .DueDate = Today.AddDays(-40), .IsCompleted = True, .CompletedDate = Today.AddDays(-30)}) + manuscript.Reminders.Add(New ManuscriptReminder With {.Title = "Old", .DueDate = Today.AddDays(-60), .IsCompleted = True, .CompletedDate = Today.AddDays(-31)}) + + Dim items As List(Of DeadlineItem) = DeadlineService.Build({manuscript}, Today) + + CollectionAssert.AreEqual({"Open", "Recent", "Edge"}, items.Select(Function(item) item.Title).ToList(), + "Open items come first; Done lists the most recently completed first.") + Assert.AreEqual(DeadlineGroup.Next7Days, items(0).Group) + Assert.IsTrue(items.Skip(1).All(Function(item) item.Group = DeadlineGroup.Done AndAlso item.CompletedDate.HasValue)) + Assert.AreEqual(manuscript.Reminders(1).Id, items(1).ReminderId) + End Sub + + + Public Sub FollowUpsAndRemindersKeepTheirOwners() + Dim manuscript As New Manuscript With {.Title = "Grit scale", .CurrentStage = PaperStage.Submitted} + Dim submission As New JournalSubmission With {.JournalName = "Assessment", .SubmittedDate = Today.AddDays(-34), .FollowUpDate = Today.AddDays(-3)} + manuscript.Submissions.Add(submission) + Dim reminder As New ManuscriptReminder With {.Title = "Send draft to coauthors", .DueDate = Today} + manuscript.Reminders.Add(reminder) + + Dim items As List(Of DeadlineItem) = DeadlineService.Build({manuscript}, Today) + + Dim followUp As DeadlineItem = items(0) + Assert.AreEqual(DeadlineKind.FollowUp, followUp.Kind) + Assert.AreEqual(DeadlineGroup.Overdue, followUp.Group) + Assert.AreEqual("Follow up with Assessment", followUp.Title) + Assert.AreEqual(submission.Id, followUp.SubmissionId) + Assert.AreEqual(DeadlineKind.Reminder, items(1).Kind) + Assert.AreEqual(DeadlineGroup.Today, items(1).Group) + Assert.AreEqual(reminder.Id, items(1).ReminderId) + End Sub + + + Public Sub BuildingNeverChangesTheLibraryAndIsDeterministic() + Dim library As New List(Of Manuscript) From { + Revision("Anchoring replication", Today.AddDays(5)), + Revision("Sleep and emotion", Nothing), + New Manuscript With {.Title = "Grit scale", .CurrentStage = PaperStage.Submitted} + } + library(2).Submissions.Add(New JournalSubmission With {.JournalName = "Assessment", .FollowUpDate = Today.AddDays(-3)}) + library(2).Reminders.Add(New ManuscriptReminder With {.Title = "Check portal", .DueDate = Today.AddDays(12)}) + Dim before As String = JsonSerializer.Serialize(library) + + Dim first As String = JsonSerializer.Serialize(DeadlineService.Build(library, Today)) + Dim second As String = JsonSerializer.Serialize(DeadlineService.Build(library, Today)) + + Assert.AreEqual(before, JsonSerializer.Serialize(library), "Building the page stores nothing.") + Assert.AreEqual(first, second) + CollectionAssert.AreEqual( + {DeadlineGroup.Overdue, DeadlineGroup.Next7Days, DeadlineGroup.Later, DeadlineGroup.NoDate}, + DeadlineService.Build(library, Today).Select(Function(item) item.Group).ToList()) + End Sub + + + Public Sub PostponeChangesTheRecordThatOwnsTheDate() + Dim dated As Manuscript = Revision("Anchoring replication", Today.AddDays(2)) + Dim datedItem As DeadlineItem = DeadlineService.Build({dated}, Today).Single() + Assert.AreEqual(Today.AddDays(2), DeadlineService.SetDate(dated, datedItem, Today.AddDays(9).AddHours(15)).Value, "The previous date comes back for undo.") + Assert.AreEqual(Today.AddDays(9), dated.Submissions.Single().Decisions.Single().RevisionDeadline.Value, "Revision deadlines live on the decision, as dates.") + Assert.IsFalse(dated.RevisionDeadline.HasValue) + + Dim undated As Manuscript = Revision("Sleep and emotion", Nothing) + Dim setDeadline As DeadlineItem = DeadlineService.Build({undated}, Today).Single() + Assert.IsFalse(DeadlineService.SetDate(undated, setDeadline, Today.AddDays(14)).HasValue) + Assert.AreEqual(Today.AddDays(14), undated.Submissions.Single().Decisions.Single().RevisionDeadline.Value, "Set deadline writes the latest decision.") + Assert.AreEqual(DeadlineGroup.Later, DeadlineService.Build({undated}, Today).Single().Group, "The item now has its date.") + + ' An older record keeps the deadline on the manuscript itself. + Dim legacy As Manuscript = Revision("Older record", Nothing) + legacy.RevisionDeadline = Today.AddDays(-1) + Dim legacyItem As DeadlineItem = DeadlineService.Build({legacy}, Today).Single() + DeadlineService.SetDate(legacy, legacyItem, Today.AddDays(6)) + Assert.AreEqual(Today.AddDays(6), legacy.RevisionDeadline.Value) + Assert.IsFalse(legacy.Submissions.Single().Decisions.Single().RevisionDeadline.HasValue) + + Dim waiting As New Manuscript With {.Title = "Grit scale", .CurrentStage = PaperStage.Submitted} + Dim submission As New JournalSubmission With {.JournalName = "Assessment", .FollowUpDate = Today.AddDays(-3)} + waiting.Submissions.Add(submission) + Dim reminder As New ManuscriptReminder With {.Title = "Send draft", .DueDate = Today} + waiting.Reminders.Add(reminder) + Dim items As List(Of DeadlineItem) = DeadlineService.Build({waiting}, Today) + + DeadlineService.SetDate(waiting, items.Single(Function(item) item.Kind = DeadlineKind.FollowUp), Today.AddDays(7)) + Assert.AreEqual(Today.AddDays(7), submission.FollowUpDate.Value) + DeadlineService.SetDate(waiting, items.Single(Function(item) item.Kind = DeadlineKind.FollowUp), Nothing) + Assert.IsFalse(submission.FollowUpDate.HasValue, "Clear removes only the follow-up date.") + Assert.AreEqual(1, waiting.Submissions.Count) + + DeadlineService.SetDate(waiting, items.Single(Function(item) item.Kind = DeadlineKind.Reminder), Today.AddDays(1)) + Assert.AreEqual(Today.AddDays(1), reminder.DueDate) + Assert.ThrowsExactly(Of ArgumentException)( + Sub() DeadlineService.SetDate(waiting, items.Single(Function(item) item.Kind = DeadlineKind.Reminder), Nothing)) + End Sub + + + Public Sub DoneCompletesOnlyReminders() + Dim manuscript As New Manuscript With {.Title = "Registered report"} + Dim reminder As New ManuscriptReminder With {.Title = "Upload preregistration", .DueDate = Today.AddDays(-1)} + manuscript.Reminders.Add(reminder) + Dim item As DeadlineItem = DeadlineService.Build({manuscript}, Today).Single() + + Assert.AreSame(reminder, DeadlineService.Complete(manuscript, item, Today.AddHours(10))) + Assert.IsTrue(reminder.IsCompleted) + Assert.AreEqual(Today, reminder.CompletedDate.Value) + Assert.AreEqual(DeadlineGroup.Done, DeadlineService.Build({manuscript}, Today).Single().Group) + + Dim revisionItem As DeadlineItem = DeadlineService.Build({Revision("Anchoring replication", Today)}, Today).Single() + Assert.ThrowsExactly(Of InvalidOperationException)(Sub() DeadlineService.Complete(manuscript, revisionItem, Today)) + End Sub + + + Public Sub TheRailCountsOnlyWhatIsDueNow() + Dim library As New List(Of Manuscript) From { + Revision("Overdue", Today.AddDays(-2)), + Revision("Today", Today), + Revision("Soon", Today.AddDays(1)), + Revision("Undated", Nothing) + } + Assert.AreEqual(2, DeadlineService.CountDueNow(library, Today)) + End Sub + + Private Shared Function Revision(title As String, deadline As DateTime?) As Manuscript + Dim manuscript As New Manuscript With {.Title = title, .CurrentStage = PaperStage.Revision, .Location = ManuscriptLocation.Pipeline} + Dim submission As New JournalSubmission With {.JournalName = "Collabra: Psychology", .SubmittedDate = Today.AddDays(-60)} + submission.Decisions.Add(New EditorialDecisionEvent With { + .Decision = EditorialDecision.MajorRevision, + .DecisionDate = Today.AddDays(-30), + .RevisionDeadline = deadline + }) + manuscript.Submissions.Add(submission) + Return manuscript + End Function + +End Class diff --git a/PaperRoute.Tests/ManuscriptPageTests.vb b/PaperRoute.Tests/ManuscriptPageTests.vb index 7853c84..53b8176 100644 --- a/PaperRoute.Tests/ManuscriptPageTests.vb +++ b/PaperRoute.Tests/ManuscriptPageTests.vb @@ -4,6 +4,7 @@ Imports System.Linq Imports System.Reflection Imports System.Runtime.ExceptionServices Imports System.Threading +Imports System.Threading.Tasks Imports System.Windows.Forms Imports ManuscriptPipeline Imports ManuscriptPipeline.Controls @@ -430,6 +431,243 @@ Public Class ManuscriptPageTests End Sub) End Sub + ' Deadlines (#28): grouped by when, filtered by kind or text, and every + ' action changes the record that owns the date. + + Public Sub DeadlinesGroupFilterAndChangeTheOwningRecord() + RunOnStaThread( + Sub() + Using board As New PageBoard() + Dim today As DateTime = DateTime.Today + Dim revision As Manuscript = WithRoute() + Dim decision As EditorialDecisionEvent = revision.Submissions(1).Decisions.Single() + decision.RevisionDeadline = today.AddDays(-2) + Dim waiting As Manuscript = Sample("Grit scale") + waiting.CurrentStage = PaperStage.Submitted + Dim submission As New JournalSubmission With {.JournalName = "Assessment", .SubmittedDate = today.AddDays(-30), .FollowUpDate = today.AddDays(3)} + waiting.Submissions.Add(submission) + Dim reminder As New ManuscriptReminder With {.Title = "Send draft to coauthors", .DueDate = today} + waiting.Reminders.Add(reminder) + waiting.Reminders.Add(New ManuscriptReminder With {.Title = "Upload preregistration", .DueDate = today.AddDays(-4), .IsCompleted = True, .CompletedDate = today.AddDays(-1)}) + board.Prepare(revision, waiting) + + Dim rail As RailButton = DirectCast(board.RailPage("Deadlines"), RailButton) + Assert.AreEqual(2, rail.Badge, "The rail counts what is overdue or due today.") + + board.PressCommandKey(Keys.Control Or Keys.D4) + Assert.AreEqual("Deadlines", board.PageName) + Assert.IsTrue(rail.Checked) + CollectionAssert.AreEqual({"OVERDUE 1", "TODAY 1", "NEXT 7 DAYS 1"}, + Descendants(board).OfType(Of Label)().Where(Function(label) label.Text.StartsWith("OVERDUE") OrElse label.Text.StartsWith("TODAY") OrElse + label.Text.StartsWith("NEXT") OrElse label.Text.StartsWith("LATER") OrElse + label.Text.StartsWith("NO DATE")).Select(Function(label) label.Text).ToList()) + CollectionAssert.AreEqual({"Revision due", "Send draft to coauthors", "Follow up with Assessment"}, + DeadlineRows(board).Select(Function(row) row.Item.Title).ToList(), "Done stays folded away.") + Dim revisionRow As DeadlineRow = DeadlineRows(board).First() + StringAssert.Contains(revisionRow.AccessibleName, "Overdue") + StringAssert.Contains(revisionRow.AccessibleName, "0 of 2 comments", "A revision shows its reviewer-comment progress.") + CollectionAssert.AreEqual({"Open", "Postpone..."}, revisionRow.ActionTexts.ToList()) + + Descendants(board).OfType(Of LinkLabel)().Single(Function(link) link.Text.StartsWith("Done in the last")).Links(0).Enabled = True + ClickDoneLink(board) + Assert.AreEqual(4, DeadlineRows(board).Count, "Show lists recently completed reminders.") + ClickDoneLink(board) + + ClickControl(Descendants(board).OfType(Of FilterChip)().Single(Function(chip) chip.Text.StartsWith("Follow-ups"))) + CollectionAssert.AreEqual({"Follow up with Assessment"}, DeadlineRows(board).Select(Function(row) row.Item.Title).ToList()) + ClickControl(Descendants(board).OfType(Of FilterChip)().Single(Function(chip) chip.Text.StartsWith("Follow-ups"))) + Assert.AreEqual(3, DeadlineRows(board).Count, "Clicking the active chip shows everything again.") + + Dim filter As TextBox = Descendants(board).OfType(Of TextBox)().Single(Function(box) box.AccessibleName = "Filter deadlines") + filter.Text = "grit" + Application.DoEvents() + Assert.IsTrue(DeadlineRows(board).All(Function(row) row.Item.ManuscriptTitle = "Grit scale")) + filter.Text = String.Empty + Application.DoEvents() + + ' Postpone asks for a date and writes the submission's follow-up. + board.SetDeadlinePrompts(Function(item) today.AddDays(10), Function(question) True) + DeadlineRows(board).Single(Function(row) row.Item.Kind = DeadlineKind.FollowUp).RunAction("Postpone...") + Application.DoEvents() + Assert.AreEqual(today.AddDays(10), submission.FollowUpDate.Value) + Assert.AreEqual(1, board.SaveCount) + Assert.AreEqual(DeadlineGroup.Later, DeadlineRows(board).Single(Function(row) row.Item.Kind = DeadlineKind.FollowUp).Item.Group) + + DeadlineRows(board).Single(Function(row) row.Item.Kind = DeadlineKind.Reminder).RunAction("Done") + Application.DoEvents() + Assert.IsTrue(reminder.IsCompleted) + Assert.AreEqual(2, board.SaveCount) + Assert.AreEqual(1, rail.Badge, "The badge follows the change.") + + DeadlineRows(board).Single(Function(row) row.Item.Kind = DeadlineKind.FollowUp).RunAction("Clear Follow-up...") + Application.DoEvents() + Assert.IsFalse(submission.FollowUpDate.HasValue) + Assert.AreEqual(1, waiting.Submissions.Count, "Clearing leaves the submission as it was.") + Assert.AreEqual(3, board.SaveCount) + + ' Open lands on the revision's reviewer responses. + DeadlineRows(board).Single().RunAction("Open") + Application.DoEvents() + Assert.AreEqual("Manuscript", board.PageName) + Assert.IsTrue(Descendants(board.Editor).OfType(Of RadioButton)().Single(Function(tab) tab.Text = "Submissions").Checked) + Dim detail As SubmissionDetailsForm = Descendants(board.Editor).OfType(Of SubmissionDetailsForm)().Single() + Assert.AreEqual("Second Journal", DirectCast(GetType(SubmissionDetailsForm).GetField("_submission", BindingFlags.Instance Or BindingFlags.NonPublic).GetValue(detail), JournalSubmission).JournalName) + Assert.AreEqual("Reviewer Responses", Descendants(detail).OfType(Of TabControl)().First().SelectedTab.Text) + + board.PressCommandKey(Keys.Alt Or Keys.Left) + Assert.AreEqual("Deadlines", board.PageName, "Back returns to Deadlines.") + Assert.AreEqual(3, board.SaveCount, "Opening and returning save nothing.") + + board.Close() + End Using + End Sub) + End Sub + + ' Nothing to act on: a sentence, not an empty list. + + Public Sub DeadlinesExplainAnEmptyList() + RunOnStaThread( + Sub() + Using board As New PageBoard() + board.Prepare(Sample("Draft only")) + Descendants(board).OfType(Of LinkLabel)().Single(Function(link) link.Text = "View in Deadlines →").Links(0).Enabled = True + board.PressCommandKey(Keys.Control Or Keys.D4) + Assert.AreEqual(0, DeadlineRows(board).Count) + Assert.IsTrue(Descendants(board).OfType(Of Label)().Any(Function(label) label.Text.StartsWith("Nothing needs action right now."))) + Assert.AreEqual(0, DirectCast(board.RailPage("Deadlines"), RailButton).Badge) + board.Close() + End Using + End Sub) + End Sub + + ' Possible publications (#61) wait on Deadlines until Mark Published or + ' Ignore; each choice is saved. + + Public Sub PossiblePublicationsAreReviewedOnDeadlines() + RunOnStaThread( + Sub() + Using board As New PageBoard() + Dim first As Manuscript = Sample("Anchoring effects in clinical risk estimates") + first.CurrentStage = PaperStage.UnderReview + first.PublicationMatches.Add(New PublicationMatch With {.Doi = "10.5555/anchoring", .Journal = "Collabra: Psychology", .PublishedDate = DateTime.Today.AddDays(-3)}) + Dim second As Manuscript = Sample("Grit across four countries") + second.CurrentStage = PaperStage.Submitted + second.PublicationMatches.Add(New PublicationMatch With {.Doi = "10.5555/grit", .Journal = "Assessment"}) + board.Prepare(first, second) + board.markPublishedPrompt = Function(manuscript, match) True + + board.PressCommandKey(Keys.Control Or Keys.D4) + Dim chip As FilterChip = Descendants(board).OfType(Of FilterChip)().Single(Function(candidate) candidate.Text.StartsWith("Publications")) + Assert.AreEqual("Publications 2", chip.Text) + Assert.IsTrue(chip.Visible) + Dim row As DeadlineRow = DeadlineRows(board).First(Function(candidate) candidate.Item.ManuscriptId = first.Id) + Assert.AreEqual("May have been published in Collabra: Psychology", row.Item.Title) + CollectionAssert.AreEqual({"Review Match", "Mark Published...", "Ignore Match", "Open Manuscript"}, row.ActionTexts.ToList()) + + row.RunAction("Mark Published...") + Application.DoEvents() + Assert.AreEqual(PaperStage.Published, board.Library(0).CurrentStage) + Assert.AreEqual(ManuscriptLocation.Published, board.Library(0).Location) + Assert.AreEqual("10.5555/anchoring", board.Library(0).Metadata.Doi) + Assert.AreEqual(1, board.SaveCount) + + DeadlineRows(board).Single(Function(candidate) candidate.Item.Kind = DeadlineKind.Publication).RunAction("Ignore Match") + Application.DoEvents() + Assert.AreEqual(PublicationMatchStatus.Ignored, second.PublicationMatches.Single().Status) + Assert.AreEqual(PaperStage.Submitted, second.CurrentStage, "Ignore changes nothing else.") + Assert.AreEqual(2, board.SaveCount) + Assert.IsFalse(Descendants(board).OfType(Of FilterChip)().Single(Function(candidate) candidate.Text.StartsWith("Publications")).Visible, + "The Publications chip appears only while there is something to review.") + + board.Close() + End Using + End Sub) + End Sub + + + Public Sub PublicationCheckFindsKeepsAndMarksPublished() + RunOnStaThread( + Sub() + Dim tracked As Manuscript = Sample("Measurement invariance of a short grit scale across four countries") + tracked.CurrentStage = PaperStage.UnderReview + tracked.Submissions.Add(New JournalSubmission With {.JournalName = "Assessment", .SubmittedDate = DateTime.Today.AddDays(-120)}) + Dim idea As Manuscript = Sample("An idea that has not been submitted anywhere yet") + Dim library As New List(Of Manuscript) From {tracked, idea} + Dim saves As Integer = 0 + Dim source As New OneTitleSource(tracked.Title, New CrossrefMetadataSuggestion With { + .Doi = "10.5555/grit", .Title = tracked.Title, .Journal = "Assessment", .WorkType = "journal-article", .PublishedDate = DateTime.Today.AddDays(-2)}) + + Using dialog As New PublicationCheckForm(library, Nothing, String.Empty, source, Function() + saves += 1 + Return True + End Function, DateTime.Today) + dialog.Pause = TimeSpan.Zero + dialog.ConfirmMarkPublished = Function(manuscript, match) True + ShowOffscreen(dialog) + CollectionAssert.AreEqual({tracked}, dialog.SelectedManuscripts, "Work that has gone to a journal is checked by default.") + + Dim check As Task = dialog.CheckAsync() + While Not check.IsCompleted + Application.DoEvents() + End While + check.GetAwaiter().GetResult() + + Assert.AreEqual(PublicationMatchStatus.Pending, tracked.PublicationMatches.Single().Status, "A found match is kept for later review.") + Assert.AreEqual(1, saves) + Assert.AreEqual(PaperStage.UnderReview, tracked.CurrentStage, "Finding a match changes nothing else.") + Assert.IsTrue(Descendants(dialog).OfType(Of Label)().Any(Function(label) label.Text = "A publication matching this manuscript may have appeared.")) + + Descendants(dialog).OfType(Of Button)().Single(Function(button) button.Text = "Mark Published...").PerformClick() + Application.DoEvents() + Assert.AreEqual(PaperStage.Published, tracked.CurrentStage) + Assert.AreEqual(2, saves) + Assert.IsTrue(dialog.Changed) + Assert.IsTrue(Descendants(dialog).OfType(Of Label)().Any(Function(label) label.Text.StartsWith("Marked published.") AndAlso label.Visible)) + dialog.Close() + End Using + End Sub) + End Sub + + Private NotInheritable Class OneTitleSource + Implements IPublicationSource + + Private ReadOnly _title As String + Private ReadOnly _work As CrossrefMetadataSuggestion + + Public Sub New(title As String, work As CrossrefMetadataSuggestion) + _title = title + _work = work + End Sub + + Public Function LookupDoiAsync(doi As String, cancellationToken As Threading.CancellationToken) As Task(Of CrossrefMetadataSuggestion) Implements IPublicationSource.LookupDoiAsync + Return Task.FromResult(Of CrossrefMetadataSuggestion)(Nothing) + End Function + + Public Function SearchTitleAsync(title As String, cancellationToken As Threading.CancellationToken) As Task(Of List(Of CrossrefMetadataSuggestion)) Implements IPublicationSource.SearchTitleAsync + Return Task.FromResult(If(title = _title, New List(Of CrossrefMetadataSuggestion) From {_work}, New List(Of CrossrefMetadataSuggestion)())) + End Function + + Public Function OrcidWorksAsync(orcid As String, cancellationToken As Threading.CancellationToken) As Task(Of List(Of OrcidWorkSuggestion)) Implements IPublicationSource.OrcidWorksAsync + Return Task.FromResult(New List(Of OrcidWorkSuggestion)()) + End Function + End Class + + Private Shared Function DeadlineRows(board As PageBoard) As List(Of DeadlineRow) + Return Descendants(board).OfType(Of DeadlineRow)().ToList() + End Function + + Private Shared Sub ClickDoneLink(board As PageBoard) + Dim link As LinkLabel = Descendants(board).OfType(Of LinkLabel)().Single(Function(candidate) candidate.Text.StartsWith("Done in the last")) + GetType(LinkLabel).GetMethod("OnLinkClicked", BindingFlags.Instance Or BindingFlags.NonPublic). + Invoke(link, New Object() {New LinkLabelLinkClickedEventArgs(link.Links(0))}) + Application.DoEvents() + End Sub + + Private Shared Sub ClickControl(control As Control) + GetType(Control).GetMethod("OnClick", BindingFlags.Instance Or BindingFlags.NonPublic).Invoke(control, New Object() {EventArgs.Empty}) + Application.DoEvents() + End Sub + Private Shared Sub MoveFirstResponseDown(board As PageBoard) Dim matrix As ReviewerResponseMatrixForm = Descendants(board.Editor).OfType(Of ReviewerResponseMatrixForm)().Single() Dim list As ListBox = DirectCast(GetType(ReviewerResponseMatrixForm).GetField("lstResponses", BindingFlags.Instance Or BindingFlags.NonPublic).GetValue(matrix), ListBox) @@ -583,6 +821,11 @@ Public Class ManuscriptPageTests Return buttons.Values.Cast(Of RadioButton)().Single(Function(button) button.Text = text) End Function + Public Sub SetDeadlinePrompts(datePrompt As Func(Of DeadlineItem, DateTime?), confirmPrompt As Func(Of String, Boolean)) + deadlineDatePrompt = datePrompt + deadlineConfirmPrompt = confirmPrompt + End Sub + ' A method rather than a property, which WinForms would try to serialize. Public Sub SetPrompt(value As Func(Of String, DialogResult)) unsavedChangesPrompt = value diff --git a/PaperRoute.Tests/PublicationCheckTests.vb b/PaperRoute.Tests/PublicationCheckTests.vb new file mode 100644 index 0000000..bdbdc0e --- /dev/null +++ b/PaperRoute.Tests/PublicationCheckTests.vb @@ -0,0 +1,283 @@ +Imports System +Imports System.Collections.Generic +Imports System.Linq +Imports System.Net.Http +Imports System.Text.Json +Imports System.Threading +Imports System.Threading.Tasks +Imports Microsoft.VisualStudio.TestTools.UnitTesting +Imports ManuscriptPipeline.Models +Imports ManuscriptPipeline.Services + +' The publication check (#61), with synthetic Crossref and ORCID answers. +' A check finds possible publications; only Mark Published changes a +' manuscript, and Fill Blanks never replaces a value. + +Public Class PublicationCheckTests + + Private Shared ReadOnly Today As New DateTime(2026, 9, 28) + Private Const LongTitle As String = "A preregistered replication of anchoring effects in clinical risk estimates" + + + + + + + + + + Public Sub TitlesMatchRegardlessOfCasePunctuationAndSmallDifferences(candidate As String, expected As Boolean) + Assert.AreEqual(expected, PublicationMatchService.IsSameTitle(LongTitle, candidate), PublicationMatchService.TitleSimilarity(LongTitle, candidate).ToString()) + Assert.AreEqual(1.0, PublicationMatchService.TitleSimilarity("Élan and naïve café", "elan AND naive cafe!"), "Accents and case do not matter.") + End Sub + + + Public Sub OnlyWorkThatIsNotPublishedIsCheckedAndGoneToAJournalIsSuggested() + Dim idea As New Manuscript With {.CurrentStage = PaperStage.Idea} + Dim review As New Manuscript With {.CurrentStage = PaperStage.UnderReview} + Dim drawer As New Manuscript With {.CurrentStage = PaperStage.Submitted, .Location = ManuscriptLocation.FileDrawer} + Dim published As New Manuscript With {.CurrentStage = PaperStage.Published, .Location = ManuscriptLocation.Published} + + CollectionAssert.AreEqual({True, True, True, False}, {idea, review, drawer, published}.Select(Function(item) PublicationMatchService.IsEligible(item)).ToList()) + CollectionAssert.AreEqual({False, True, False, False}, {idea, review, drawer, published}.Select(Function(item) PublicationMatchService.IsSuggested(item)).ToList()) + End Sub + + + Public Sub ConsiderAcceptsOnlyPlausibleNewPublications() + Dim manuscript As Manuscript = Tracked() + manuscript.Metadata.PreprintDoi = "10.31234/osf.io/abcde" + + Assert.IsNotNull(PublicationMatchService.Consider(manuscript, Work("10.5555/a", LongTitle, "Collabra", Today.AddDays(-10)), PublicationMatchSource.Title)) + Assert.IsNull(PublicationMatchService.Consider(manuscript, Work("10.5555/b", LongTitle, "PsyArXiv", Today, "posted-content"), PublicationMatchSource.Title), "A preprint is not a publication.") + Assert.IsNull(PublicationMatchService.Consider(manuscript, Work("10.5555/c", "Anchoring in pricing", "Collabra", Today), PublicationMatchSource.Title), "A different title is another work.") + Assert.IsNull(PublicationMatchService.Consider(manuscript, Work("10.5555/d", LongTitle, "Collabra", New DateTime(2024, 1, 1)), PublicationMatchSource.Title), + "A work published long before the first submission is an older one.") + Assert.IsNull(PublicationMatchService.Consider(manuscript, Work("10.31234/osf.io/abcde", LongTitle, "Collabra", Today), PublicationMatchSource.Title), "The manuscript's own preprint is not its publication.") + Assert.IsNull(PublicationMatchService.Consider(manuscript, Work("10.5555/e", LongTitle, "", Today), PublicationMatchSource.Doi), "A DOI without a venue is not yet published.") + + Dim shortTitle As New Manuscript With {.Title = "Introduction", .CurrentStage = PaperStage.Submitted} + Assert.IsNull(PublicationMatchService.Consider(shortTitle, Work("10.5555/f", "Introduction", "Any Journal", Today), PublicationMatchSource.Title), "Short titles are too common to match.") + + manuscript.PublicationMatches.Add(New PublicationMatch With {.Doi = "10.5555/A", .Status = PublicationMatchStatus.Ignored}) + Assert.IsNull(PublicationMatchService.Consider(manuscript, Work("https://doi.org/10.5555/a", LongTitle, "Collabra", Today), PublicationMatchSource.Title), "An ignored match never returns.") + End Sub + + + Public Sub MarkPublishedRecordsALifecycleEventAndFillsOnlyBlanks() + Dim manuscript As Manuscript = Tracked() + manuscript.Location = ManuscriptLocation.FileDrawer + manuscript.FileDrawerReason = "Waiting" + manuscript.Metadata.Pages = "e123" + Dim submissions As String = JsonSerializer.Serialize(manuscript.Submissions) + Dim match As New PublicationMatch With { + .Doi = "10.1525/collabra.1", .Title = LongTitle, .Journal = "Collabra: Psychology", + .PublishedDate = Today.AddDays(-12), .Volume = "12", .Pages = "1-20", .Url = "https://online.ucpress.edu/collabra/article/12/1/1" + } + Dim other As New PublicationMatch With {.Doi = "10.5555/other", .Journal = "Other"} + manuscript.PublicationMatches.AddRange({match, other}) + + Dim described As List(Of String) = PublicationMatchService.DescribeMarkPublished(manuscript, match, Today) + StringAssert.Contains(described(0), "Published shelf") + Assert.IsTrue(described.Any(Function(line) line.Contains("from Open Psychology Letters to Collabra: Psychology"))) + StringAssert.Contains(described.Last(), "DOI, Journal, Publication date, Volume, Publication URL") + + PublicationMatchService.MarkPublished(manuscript, match, Today) + + Assert.AreEqual(PaperStage.Published, manuscript.CurrentStage) + Assert.AreEqual(ManuscriptLocation.Published, manuscript.Location) + Assert.AreEqual(Today.AddDays(-12), manuscript.StageEnteredDate) + Assert.AreEqual("Collabra: Psychology", manuscript.TargetJournal) + Assert.IsFalse(manuscript.TargetJournalId.HasValue, "A changed journal no longer points at the old Journal Library record.") + Assert.AreEqual(String.Empty, manuscript.FileDrawerReason) + Dim published As HistoryEvent = manuscript.History.Last() + Assert.AreEqual(PaperStage.Published, published.Stage) + StringAssert.Contains(published.Note, "Published in Collabra: Psychology (DOI 10.1525/collabra.1)") + Assert.IsTrue(published.RecordedAtUtc.HasValue) + Assert.AreEqual("10.1525/collabra.1", manuscript.Metadata.Doi) + Assert.AreEqual("12", manuscript.Metadata.Volume) + Assert.AreEqual("e123", manuscript.Metadata.Pages, "A field with a value is kept.") + Assert.AreEqual(submissions, JsonSerializer.Serialize(manuscript.Submissions), "Submissions and decisions are left as recorded.") + Assert.AreEqual(PublicationMatchStatus.Confirmed, match.Status) + Assert.AreEqual(PublicationMatchStatus.Ignored, other.Status, "Other possible matches no longer need review.") + End Sub + + + Public Sub FillPlansOnlyEmptyFields() + Dim manuscript As New Manuscript With {.Title = "Grit scale"} + manuscript.Metadata.Doi = "10.5555/grit" + manuscript.Metadata.PublicationJournal = "Assessment" + manuscript.Metadata.Keywords.Add("grit") + Dim record As CrossrefMetadataSuggestion = Work("10.5555/grit", "Grit scale", "Another name for the journal", Today) + record.Volume = "33" + record.AbstractText = "An abstract." + record.Keywords = New List(Of String) From {"personality"} + + Dim plan As List(Of MetadataFill) = PublicationMatchService.PlanFill(manuscript, record) + + CollectionAssert.AreEqual({"Publication date", "Volume", "Abstract"}, plan.Select(Function(fill) fill.Field).ToList()) + PublicationMatchService.ApplyFill(manuscript, plan) + Assert.AreEqual("Assessment", manuscript.Metadata.PublicationJournal) + Assert.AreEqual("33", manuscript.Metadata.Volume) + CollectionAssert.AreEqual({"grit"}, manuscript.Metadata.Keywords) + Assert.AreEqual(0, PublicationMatchService.PlanFill(manuscript, record).Count, "Filling twice changes nothing.") + End Sub + + + Public Sub SearchResultsAndPreprintRelationsAreRead() + Dim search As String = + "{""message"":{""items"":[" & + "{""DOI"":""10.5555/one"",""title"":[""One""],""container-title"":[""Journal A""],""type"":""journal-article"",""volume"":""4"",""issued"":{""date-parts"":[[2026,5]]}}," & + "{""title"":[""No DOI""]}," & + "{""DOI"":""10.31234/two"",""title"":[""Two""],""type"":""posted-content"",""relation"":{""is-preprint-of"":[{""id-type"":""doi"",""id"":""10.5555/two-published""},{""id-type"":""uri"",""id"":""https://example.org""}]}}" & + "]}}" + + Dim works As List(Of CrossrefMetadataSuggestion) = CrossrefClient.ParseSearchJson(search) + + Assert.AreEqual(2, works.Count, "Items without a DOI are skipped.") + Assert.AreEqual("journal-article", works(0).WorkType) + Assert.AreEqual("4", works(0).Volume) + Assert.AreEqual(New DateTime(2026, 5, 1), works(0).PublishedDate) + CollectionAssert.AreEqual({"10.5555/two-published"}, works(1).PublishedVersionDois) + Assert.ThrowsExactly(Of InvalidOperationException)(Sub() CrossrefClient.ParseSearchJson("{""message"":{}}")) + End Sub + + + Public Sub CheckLooksByDoiPreprintTitleAndOrcidWithoutChangingManuscripts() + Dim byDoi As Manuscript = Tracked("Measurement invariance of a short grit scale across four countries") + byDoi.Metadata.Doi = "10.5555/grit" + Dim byPreprint As Manuscript = Tracked("Sleep and emotional memory consolidation in older adults") + byPreprint.Metadata.PreprintDoi = "10.31234/sleep" + Dim byTitle As Manuscript = Tracked(LongTitle) + Dim byOrcid As Manuscript = Tracked("Teaching open science to nursing students with a mixed methods evaluation") + Dim nothingFound As Manuscript = Tracked("Attention capture by salient distractors under working memory load") + Dim published As New Manuscript With {.Title = "Already published work in this library", .CurrentStage = PaperStage.Published, .Location = ManuscriptLocation.Published} + + Dim source As New FakeSource() + source.Dois("10.5555/grit") = Work("10.5555/grit", byDoi.Title, "Assessment", Today.AddDays(-3)) + Dim preprint As CrossrefMetadataSuggestion = Work("10.31234/sleep", byPreprint.Title, "PsyArXiv", Today.AddDays(-90), "posted-content") + preprint.PublishedVersionDois.Add("10.5555/sleep") + source.Dois("10.31234/sleep") = preprint + source.Dois("10.5555/sleep") = Work("10.5555/sleep", "Sleep and emotional memory consolidation in older adults", "Aging & Cognition", Today.AddDays(-5)) + source.Titles(LongTitle) = New List(Of CrossrefMetadataSuggestion) From { + Work("10.5555/near", "Anchoring effects in clinical settings", "Medical Decision Making", Today), + Work("10.5555/anchoring", LongTitle, "Collabra: Psychology", Today.AddDays(-1)) + } + source.Works.Add(New OrcidWorkSuggestion With {.Title = byOrcid.Title, .JournalTitle = "Nurse Education Today", .PublishedDate = Today.AddDays(-20), .WorkType = "journal-article"}) + + Dim library As New List(Of Manuscript) From {byDoi, byPreprint, byTitle, byOrcid, nothingFound, published} + Dim before As String = JsonSerializer.Serialize(library) + + Dim result As PublicationCheckResult = PublicationCheckService.CheckAsync(library, "0000-0002-1825-0097", source).GetAwaiter().GetResult() + + Assert.AreEqual(before, JsonSerializer.Serialize(library), "A check never changes a manuscript.") + Assert.AreEqual(5, result.Checked, "Published work is not checked.") + Assert.AreEqual(0, result.Failures.Count) + Dim found = result.Matches.ToDictionary(Function(entry) entry.Manuscript, Function(entry) entry.Match) + Assert.AreEqual(PublicationMatchSource.Doi, found(byDoi).Source) + Assert.AreEqual("10.5555/sleep", found(byPreprint).Doi) + Assert.AreEqual(PublicationMatchSource.Preprint, found(byPreprint).Source) + Assert.AreEqual("10.5555/anchoring", found(byTitle).Doi, "The closest title wins.") + Assert.AreEqual(PublicationMatchSource.Orcid, found(byOrcid).Source) + Assert.AreEqual("Nurse Education Today", found(byOrcid).Journal) + Assert.IsFalse(found.ContainsKey(nothingFound)) + Assert.AreEqual(1, source.OrcidReads, "The ORCID record is read once per check.") + End Sub + + + Public Sub NetworkTroubleSkipsOneManuscriptAndRateLimitingStopsKeepingWhatWasFound() + Dim first As Manuscript = Tracked(LongTitle) + Dim broken As Manuscript = Tracked("Measurement invariance of a short grit scale across four countries") + Dim limited As Manuscript = Tracked("Sleep and emotional memory consolidation in older adults") + Dim never As Manuscript = Tracked("Teaching open science to nursing students with a mixed methods evaluation") + + Dim source As New FakeSource() + source.Titles(LongTitle) = New List(Of CrossrefMetadataSuggestion) From {Work("10.5555/anchoring", LongTitle, "Collabra", Today)} + source.Failures(broken.Title) = New HttpRequestException("No connection.") + source.Failures(limited.Title) = New CrossrefRateLimitException() + + Dim result As PublicationCheckResult = + PublicationCheckService.CheckAsync(New List(Of Manuscript) From {first, broken, limited, never}, Nothing, source).GetAwaiter().GetResult() + + Assert.AreEqual(1, result.Matches.Count, "What was found before stopping is kept.") + Assert.AreSame(broken, result.Failures.Single().Manuscript) + StringAssert.Contains(result.Failures.Single().Reason, "No connection") + StringAssert.Contains(result.StoppedReason, "rate-limiting") + Assert.IsFalse(source.Searched.Contains(never.Title), "Nothing more is asked after Crossref asks PaperRoute to slow down.") + End Sub + + + Public Sub PendingMatchesAppearOnDeadlinesUntilReviewed() + Dim manuscript As Manuscript = Tracked(LongTitle) + Dim pending As New PublicationMatch With {.Doi = "10.5555/a", .Journal = "Collabra: Psychology"} + manuscript.PublicationMatches.AddRange({pending, New PublicationMatch With {.Doi = "10.5555/b", .Status = PublicationMatchStatus.Ignored}}) + + Dim item As DeadlineItem = DeadlineService.Build({manuscript}, Today).Single(Function(entry) entry.Kind = DeadlineKind.Publication) + Assert.AreEqual(DeadlineGroup.NoDate, item.Group) + Assert.AreEqual("May have been published in Collabra: Psychology", item.Title) + Assert.AreEqual(pending.Id, item.PublicationMatchId) + Assert.AreEqual(0, DeadlineService.CountDueNow({manuscript}, Today), "Possible publications have no date and never count as due.") + + PublicationMatchService.Ignore(pending) + Assert.IsFalse(DeadlineService.Build({manuscript}, Today).Any(Function(entry) entry.Kind = DeadlineKind.Publication)) + End Sub + + + Public Sub InvalidStoredMatchesAreRejectedAndMissingListsAreEmpty() + Dim manuscript As New Manuscript With {.PublicationMatches = Nothing} + PublicationMatchService.NormalizeAndValidateManuscript(manuscript) + Assert.AreEqual(0, manuscript.PublicationMatches.Count) + + manuscript.PublicationMatches.Add(New PublicationMatch With {.Status = CType(9, PublicationMatchStatus)}) + Assert.ThrowsExactly(Of System.IO.InvalidDataException)(Sub() PublicationMatchService.NormalizeAndValidateManuscript(manuscript)) + manuscript.PublicationMatches(0) = Nothing + Assert.ThrowsExactly(Of System.IO.InvalidDataException)(Sub() PublicationMatchService.NormalizeAndValidateManuscript(manuscript)) + End Sub + + ' Under review at a second journal after a first submission in 2026. + Private Shared Function Tracked(Optional title As String = LongTitle) As Manuscript + Dim manuscript As New Manuscript With { + .Title = title, + .CurrentStage = PaperStage.UnderReview, + .Location = ManuscriptLocation.Pipeline, + .TargetJournal = "Open Psychology Letters", + .TargetJournalId = Guid.NewGuid() + } + manuscript.Submissions.Add(New JournalSubmission With {.JournalName = "Open Psychology Letters", .SubmittedDate = New DateTime(2026, 2, 1)}) + Return manuscript + End Function + + Private Shared Function Work(doi As String, title As String, journal As String, published As DateTime?, Optional type As String = "journal-article") As CrossrefMetadataSuggestion + Return New CrossrefMetadataSuggestion With {.Doi = doi, .Title = title, .Journal = journal, .PublishedDate = published, .WorkType = type} + End Function + + Private NotInheritable Class FakeSource + Implements IPublicationSource + + Public ReadOnly Dois As New Dictionary(Of String, CrossrefMetadataSuggestion)(StringComparer.OrdinalIgnoreCase) + Public ReadOnly Titles As New Dictionary(Of String, List(Of CrossrefMetadataSuggestion))() + Public ReadOnly Failures As New Dictionary(Of String, Exception)() + Public ReadOnly Works As New List(Of OrcidWorkSuggestion)() + Public ReadOnly Searched As New List(Of String)() + Public OrcidReads As Integer + + Public Function LookupDoiAsync(doi As String, cancellationToken As CancellationToken) As Task(Of CrossrefMetadataSuggestion) Implements IPublicationSource.LookupDoiAsync + Dim work As CrossrefMetadataSuggestion = Nothing + Dois.TryGetValue(doi, work) + Return Task.FromResult(work) + End Function + + Public Function SearchTitleAsync(title As String, cancellationToken As CancellationToken) As Task(Of List(Of CrossrefMetadataSuggestion)) Implements IPublicationSource.SearchTitleAsync + Searched.Add(title) + Dim failure As Exception = Nothing + If Failures.TryGetValue(title, failure) Then Return Task.FromException(Of List(Of CrossrefMetadataSuggestion))(failure) + Dim found As List(Of CrossrefMetadataSuggestion) = Nothing + Return Task.FromResult(If(Titles.TryGetValue(title, found), found, New List(Of CrossrefMetadataSuggestion)())) + End Function + + Public Function OrcidWorksAsync(orcid As String, cancellationToken As CancellationToken) As Task(Of List(Of OrcidWorkSuggestion)) Implements IPublicationSource.OrcidWorksAsync + OrcidReads += 1 + Return Task.FromResult(Works) + End Function + End Class + +End Class diff --git a/PaperRoute.Tests/Schema4CertificationTests.vb b/PaperRoute.Tests/Schema4CertificationTests.vb index 81175ce..c3d655f 100644 --- a/PaperRoute.Tests/Schema4CertificationTests.vb +++ b/PaperRoute.Tests/Schema4CertificationTests.vb @@ -53,12 +53,14 @@ Public Class Schema4CertificationTests Assert.AreEqual(StorageMigrationService.CurrentSchemaVersion, StorageMigrationService.ReadSchemaVersion(SchemaPath())) Dim backupPath As String = Path.Combine(_dataDirectory, "schema.v4.bak") Dim schema5BackupPath As String = Path.Combine(_dataDirectory, "schema.v5.bak") + Dim schema6BackupPath As String = Path.Combine(_dataDirectory, "schema.v6.bak") CollectionAssert.AreEqual(oldSchema, File.ReadAllBytes(backupPath)) Assert.AreEqual(5, StorageMigrationService.ReadSchemaVersion(schema5BackupPath)) + Assert.AreEqual(6, StorageMigrationService.ReadSchemaVersion(schema6BackupPath)) AssertExistingFilesUnchanged(beforeFiles, SchemaPath()) Dim afterFiles As Dictionary(Of String, Byte()) = SnapshotFiles(_root) CollectionAssert.AreEquivalent( - beforeFiles.Keys.Concat({backupPath, schema5BackupPath}).ToArray(), afterFiles.Keys.ToArray()) + beforeFiles.Keys.Concat({backupPath, schema5BackupPath, schema6BackupPath}).ToArray(), afterFiles.Keys.ToArray()) Assert.AreEqual(beforeModels, Serialize(repository.Load())) Assert.AreEqual(beforeAuthors, Serialize(authors.Load())) AssertRepresentativeSemantics(repository.Load(), authors.Load()) diff --git a/PaperRoute.Tests/Schema6MigrationTests.vb b/PaperRoute.Tests/Schema6MigrationTests.vb index 6143683..dafedf1 100644 --- a/PaperRoute.Tests/Schema6MigrationTests.vb +++ b/PaperRoute.Tests/Schema6MigrationTests.vb @@ -38,8 +38,10 @@ Public Class Schema6MigrationTests File.WriteAllText(Path.Combine(_data, "manuscripts.bak"), "preserve existing automatic backup") File.WriteAllText(Path.Combine(_data, "authors.json"), "{""Journals"":[]}") EnsureStorage() - Assert.AreEqual(6, StorageMigrationService.ReadSchemaVersion(_schema)) + ' Schema 6 then Schema 7: each step keeps the marker it replaced. + Assert.AreEqual(StorageMigrationService.CurrentSchemaVersion, StorageMigrationService.ReadSchemaVersion(_schema)) Assert.AreEqual("{""SchemaVersion"":5}", File.ReadAllText(Path.Combine(_data, "schema.v5.bak"))) + StringAssert.Contains(File.ReadAllText(Path.Combine(_data, "schema.v6.bak")), """SchemaVersion"": 6") Assert.AreEqual(original, File.ReadAllText(Path.Combine(_data, "manuscripts.json"))) Assert.AreEqual("preserve existing automatic backup", File.ReadAllText(Path.Combine(_data, "manuscripts.bak"))) Assert.AreEqual("{""Journals"":[]}", File.ReadAllText(Path.Combine(_data, "authors.json"))) @@ -114,9 +116,9 @@ Public Class Schema6MigrationTests Public Sub FutureSchema_IsRejectedBeforeAnyStorageMigration() - File.WriteAllText(_schema, "{""SchemaVersion"":7}") + File.WriteAllText(_schema, "{""SchemaVersion"":8}") Assert.ThrowsExactly(Of InvalidOperationException)(Sub() EnsureStorage()) - Assert.AreEqual(7, StorageMigrationService.ReadSchemaVersion(_schema)) + Assert.AreEqual(8, StorageMigrationService.ReadSchemaVersion(_schema)) Assert.IsFalse(Directory.Exists(Path.Combine(_root, "library"))) End Sub diff --git a/PaperRoute.Tests/Schema7MigrationTests.vb b/PaperRoute.Tests/Schema7MigrationTests.vb new file mode 100644 index 0000000..490905b --- /dev/null +++ b/PaperRoute.Tests/Schema7MigrationTests.vb @@ -0,0 +1,96 @@ +Imports System +Imports System.Collections.Generic +Imports System.IO +Imports System.Linq +Imports System.Text.Json +Imports Microsoft.VisualStudio.TestTools.UnitTesting +Imports ManuscriptPipeline.Models +Imports ManuscriptPipeline.Services + +' Schema 7 (#61) remembers publication matches. Upgrading changes only the +' marker, and a library that does not validate is left exactly as it was. + +Public Class Schema7MigrationTests + Private _root As String + Private _current As String + Private _data As String + Private _schema As String + + + Public Sub Initialize() + _root = CreateTemporaryRoot() + _current = Path.Combine(_root, "current") + _data = Path.Combine(_current, "data") + Directory.CreateDirectory(_data) + _schema = StorageMigrationService.SchemaFilePath(_current) + File.WriteAllText(_schema, "{""SchemaVersion"":6}") + End Sub + + + Public Sub Cleanup() + DeleteTemporaryRoot(_root) + End Sub + + + + + Public Sub Upgrade_PreservesOlderBytesAndLoadsNoMatches(explicitNull As Boolean) + Dim suffix = If(explicitNull, ",""PublicationMatches"":null", String.Empty) + Dim original = "[{""Id"":""11111111-1111-1111-1111-111111111111"",""Title"":""Old manuscript""" & suffix & "}]" + File.WriteAllText(Path.Combine(_data, "manuscripts.json"), original) + File.WriteAllText(Path.Combine(_data, "manuscripts.bak"), "preserve existing automatic backup") + File.WriteAllText(Path.Combine(_data, "authors.json"), "{""Journals"":[]}") + + EnsureStorage() + + Assert.AreEqual(7, StorageMigrationService.ReadSchemaVersion(_schema)) + Assert.AreEqual("{""SchemaVersion"":6}", File.ReadAllText(Path.Combine(_data, "schema.v6.bak"))) + Assert.AreEqual(original, File.ReadAllText(Path.Combine(_data, "manuscripts.json"))) + Assert.AreEqual("preserve existing automatic backup", File.ReadAllText(Path.Combine(_data, "manuscripts.bak"))) + Dim repository As New ManuscriptRepository(_data, Path.Combine(_root, "library")) + Assert.AreEqual(0, repository.Load()(0).PublicationMatches.Count) + + Dim afterFirstUpgrade = File.ReadAllText(_schema) + EnsureStorage() + Assert.AreEqual(afterFirstUpgrade, File.ReadAllText(_schema), "Upgrading twice changes nothing.") + End Sub + + + + + + + Public Sub Upgrade_RejectsInvalidExistingDataWithoutAdvancingSchema(fileName As String, data As String) + File.WriteAllText(Path.Combine(_data, fileName), data) + Assert.ThrowsExactly(Of InvalidDataException)(Sub() EnsureStorage()) + Assert.AreEqual(6, StorageMigrationService.ReadSchemaVersion(_schema)) + Assert.AreEqual(data, File.ReadAllText(Path.Combine(_data, fileName))) + Assert.IsFalse(File.Exists(Path.Combine(_data, "schema.v6.bak"))) + End Sub + + + Public Sub MatchesRoundTripAndDuplicatesAreRejected() + Dim manuscript As New Manuscript With {.Title = "Anchoring replication"} + Dim match As New PublicationMatch With { + .Doi = "10.1234/abc", .Title = "Anchoring replication", .Journal = "Collabra", + .Status = PublicationMatchStatus.Ignored, .Source = PublicationMatchSource.Orcid + } + manuscript.PublicationMatches.Add(match) + File.WriteAllText(_schema, "{""SchemaVersion"":7}") + Dim repository As New ManuscriptRepository(_data, Path.Combine(_root, "library")) + repository.Save(New List(Of Manuscript) From {manuscript}) + + Dim loaded As PublicationMatch = repository.Load()(0).PublicationMatches.Single() + Assert.AreEqual(match.Id, loaded.Id) + Assert.AreEqual(PublicationMatchStatus.Ignored, loaded.Status, "An ignored match stays ignored.") + Assert.AreEqual(PublicationMatchSource.Orcid, loaded.Source) + + manuscript.PublicationMatches.Add(New PublicationMatch With {.Id = match.Id}) + Assert.ThrowsExactly(Of InvalidDataException)(Sub() repository.Save(New List(Of Manuscript) From {manuscript})) + Assert.AreEqual(1, repository.Load()(0).PublicationMatches.Count, "A rejected save leaves the library as it was.") + End Sub + + Private Sub EnsureStorage() + StorageMigrationService.EnsureCurrentStorage(_current, Path.Combine(_root, "legacy"), Path.Combine(_root, "library"), Path.Combine(_root, "legacy-library")) + End Sub +End Class diff --git a/PaperRoute.Tests/TitlePageApplyTests.vb b/PaperRoute.Tests/TitlePageApplyTests.vb index 767e27b..2f1d633 100644 --- a/PaperRoute.Tests/TitlePageApplyTests.vb +++ b/PaperRoute.Tests/TitlePageApplyTests.vb @@ -157,6 +157,22 @@ Public Class TitlePageApplyTests End Sub + ' #76: the first-deadline date showed "10/11/202…" at 150% scaling. + + Public Sub AddManuscript_FirstDeadlineShowsTheWholeDate() + + Using dialog As New AddManuscriptForm() + dialog.SetFirstDeadline("Send draft to coauthors", New DateTime(2026, 12, 28)) + dialog.SizeDeadlinePicker() + + Dim picker As DateTimePicker = DirectCast(GetField(dialog, "dtpDeadline"), DateTimePicker) + Dim text As Integer = TextRenderer.MeasureText(picker.Value.ToString("d"), picker.Font).Width + Assert.IsTrue(picker.Width - SystemInformation.GetVerticalScrollBarWidthForDpi(dialog.DeviceDpi) >= text, + "The date and its drop-down button fit at this display's scaling.") + End Using + + End Sub + Public Sub AddManuscript_WithoutALibrary_StillAddsATitleOnlyManuscript() diff --git a/README.md b/README.md index a4f66a0..ee0ff40 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ PaperRoute Tracker helps researchers manage manuscripts from idea through submis ## Current status - **Stable:** [v0.6.0 — Workspace](https://github.com/JUhalt/PaperRoute-Tracker/releases/tag/v0.6.0) brings PaperRoute into one main window with pages, a tabbed page for each manuscript with a single save step, and a calmer card-based design, with no change to your library. See the [release notes](docs/releases/0.6.0.md) and the [user guide](docs/USER_GUIDE.md). -- **Next:** [v0.7 — Deadline Center](https://github.com/JUhalt/PaperRoute-Tracker/milestone/9) puts everything that needs action, and when, on one page. +- **Preview:** [v0.7.0-rc.1 — Deadline Center](https://github.com/JUhalt/PaperRoute-Tracker/releases/tag/v0.7.0-rc.1) replaces Reminders with a **Deadlines** page for everything that needs action, and when, and adds a user-initiated **publication check**. It upgrades the library to Schema 7; read the [release notes](docs/releases/0.7.0-rc.1.md) and [upgrade notes](UPGRADE_NOTES.md) before trying it. -v0.6 uses the same Schema 6 library as v0.5, so upgrading from v0.5 changes no data. Upgrading from v0.4? Keep a separate v0.4 backup first, and restore v0.5 backups only with v0.5 or later because older restore code may ignore reviewer-response fields. The [upgrade notes](UPGRADE_NOTES.md) explain the save and compatibility boundaries. +v0.6 uses the same Schema 6 library as v0.5, so upgrading from v0.5 changes no data. v0.7 moves the library to Schema 7, which v0.6 cannot open, so keep a backup made with v0.6 first. The [upgrade notes](UPGRADE_NOTES.md) explain the save and compatibility boundaries. **New to PaperRoute?** Start with the [PaperRoute User Guide](https://github.com/JUhalt/PaperRoute-Tracker/blob/v0.6.0/docs/USER_GUIDE.md) for a Quick Start, feature tour, and task-oriented "How do I...?" reference. @@ -45,11 +45,12 @@ v0.6 uses the same Schema 6 library as v0.5, so upgrading from v0.5 changes no d - **Editorial decisions** including desk rejection, revision requests, acceptance, and revision deadlines. - **Reviewer Response Matrix** for reviewer and editor comments, statuses, draft responses, manuscript locations, and an editable response-to-reviewers export. - **Correspondence and local-file tracking** for decision letters, reviewer comments, response letters, and revised manuscripts. -- **Local reminders and calendar export** for revision deadlines, submission follow-ups, and custom reminders, with optional Windows notifications and portable `.ics` events. +- **Deadlines** for everything that needs action, grouped Overdue, Today, Next 7 days, Later, and No date: revision deadlines with reviewer-comment progress, journal follow-ups, your reminders, and unfinished submission preparation. **Postpone** changes the date where it lives. Optional Windows notifications and portable `.ics` calendar export. ### Describe and publish - **DOI & Crossref enrichment** with a preview; only selected fields are applied, and Crossref never changes stage, shelf, or target journal. +- **Publication check**, only when you ask: has a tracked manuscript appeared, by its DOI, its preprint's published version, its title, or your ORCID works? **Mark Published** records it after showing exactly what will change; **Fill Blanks** completes empty publication fields and never replaces a value. - **ORCID public-profile import** of names, affiliations, and works, with explicit control over whether dated works go to Published. - **Reusable authors and affiliations** with manuscript-specific order, corresponding-author designation, and optional ORCID iDs. - **BibTeX and RIS** import with review-before-import and duplicate detection, plus export of selected records. @@ -177,11 +178,9 @@ The internal project/folder name remains `ManuscriptPipeline` for compatibility - GitHub Actions for Windows CI and release builds - Velopack for Windows installation and automatic updates -## Inspiration and independence +## Independence -PaperRoute was inspired by the broader idea of academic manuscript pipeline tools, including the workflow concepts presented by PaperTrek. PaperRoute is an independent open-source project and is not affiliated with or endorsed by PaperTrek. - -The implementation, local-first data model, import/export system, backup workflow, and interface are independently developed for PaperRoute. +PaperRoute is an independent, open-source project. Its implementation, local-first data model, import/export system, backup workflow, and interface are developed for PaperRoute, and it is not affiliated with any commercial manuscript-tracking service. ## Roadmap @@ -189,7 +188,7 @@ The implementation, local-first data model, import/export system, backup workflo | --- | --- | --- | | **Released** | v0.5 — Reviewer Response Workflow | Response matrix, drafting, and Markdown export | | **Released** | v0.6 — Workspace | One window, manuscript pages, one save step, calmer design | -| **Now** | [v0.7 — Deadline Center](https://github.com/JUhalt/PaperRoute-Tracker/milestone/9) | Everything that needs action, and when | +| **Preview** | [v0.7 — Deadline Center](https://github.com/JUhalt/PaperRoute-Tracker/milestone/9) | Everything that needs action, and when; the publication check | | **Then** | [v0.8 — Route Analytics & Reports](https://github.com/JUhalt/PaperRoute-Tracker/milestone/10) | Your own turnaround data, route and status reports, types and tags | | | [v0.9 — 1.0 Hardening](https://github.com/JUhalt/PaperRoute-Tracker/milestone/11) | Onboarding, accessibility, recovery, certification | | **Goal** | [v1.0 — Trusted Research Workflow](https://github.com/JUhalt/PaperRoute-Tracker/milestone/12) | "I trust this application with my research workflow." | diff --git a/ROADMAP.md b/ROADMAP.md index 21a35b7..e699ab1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -51,8 +51,8 @@ Targets are directional rather than promises. Data integrity, migrations, backup | **v0.3** | The Route ✅ | What happened to this manuscript, in what order, and which file was which? | | **v0.4** | Submission Readiness ✅ | What exactly am I preparing and sending to this journal? | | **v0.5** | Reviewer Response Workflow ✅ | What did the journal ask me to change, and how am I responding? | -| **v0.6** | **Workspace UI — active** | Can I find and act on everything from one calm, uncluttered workspace? | -| **v0.7** | Deadline Center | What requires action, and when? | +| **v0.6** | Workspace UI ✅ | Can I find and act on everything from one calm, uncluttered workspace? | +| **v0.7** | **Deadline Center — active** | What requires action, and when? | | **v0.8** | Route Analytics & Reports | What does this publication journey show me—and how can I communicate it? | | **v0.9** | 1.0 Hardening | Is the entire workflow polished, resilient, and certifiable? | | **v1.0** | Trusted Research Workflow | Would I trust this with my real publication pipeline? | @@ -257,7 +257,7 @@ Release certification included the combined regression suite, native 100%/125%/1 --- -# ACTIVE — v0.6 Workspace UI +## COMPLETE — v0.6 Workspace UI Milestone: **[v0.6.0 — Workspace UI](https://github.com/JUhalt/PaperRoute-Tracker/milestone/8)** @@ -271,11 +271,11 @@ Five releases added depth beneath each manuscript, and the interface grew by sta - **[#59 Welcome and empty states](https://github.com/JUhalt/PaperRoute-Tracker/issues/59)** — every empty area says what belongs there and offers the first action. - **[#60 Faster Add Manuscript](https://github.com/JUhalt/PaperRoute-Tracker/issues/60)** — paste a title page and review locally parsed fields; optionally set a first deadline as an ordinary reminder. -v0.6 is planned without a storage-schema change. Existing save semantics, lifecycle rules, and data-safety guarantees carry over unchanged; only their presentation moves. +PaperRoute v0.6.0 was released on September 27, 2026 from [PR #75](https://github.com/JUhalt/PaperRoute-Tracker/pull/75), after the v0.6.0-rc.1 Preview. It kept the Schema 6 library, so existing save semantics, lifecycle rules, and data-safety guarantees carried over unchanged. Certification (clean installation, native 100%/125%/150%, installed upgrades through the live Preview and Stable updaters with byte-identical libraries, and published-asset verification) is recorded in [#74](https://github.com/JUhalt/PaperRoute-Tracker/issues/74). --- -# v0.7 Deadline Center +# ACTIVE — v0.7 Deadline Center Milestone: **[v0.7.0 — Deadline Center](https://github.com/JUhalt/PaperRoute-Tracker/milestone/9)** @@ -285,6 +285,22 @@ Aggregate revision deadlines, follow-up dates, readiness/submission obligations, - **[#61 Publication check and metadata completion](https://github.com/JUhalt/PaperRoute-Tracker/issues/61)** — user-initiated checks for a possible publication of a tracked manuscript, and batch Crossref completion that fills only empty fields. Status changes only through an explicit **Mark Published** choice. +Decisions settled on September 27, 2026: + +- The page is called **Deadlines**, in the rail and as its title, and replaces **Reminders**. +- **Postpone** changes the date on the record that owns it (a submission's follow-up date, a decision's revision deadline, or a reminder's date). There is no separate snooze field, so the Deadlines page and the manuscript page cannot disagree. +- Publication-check results (possible and ignored matches) are remembered. This is v0.7's only storage change: **Schema 7**, with the same conservative migration and backup/restore coverage as Schema 6. + +Planned slices, tracked in the v0.7 integration PR: + +1. **Obligations in the engine (#28).** Revision rows carry reviewer-response progress from the decision that set the deadline. Undated work appears in a **No date** group: a revision without a deadline, a prepared packet with open checklist items, and possible publications to review. Every item links to the record that owns it; status is deterministic and tested. The journal name is filled for revision deadlines. +2. **The Deadlines page (#28).** Overdue, Today, Next 7 days, Later, No date, and Done groups; kind filters and search; Open, Postpone, Done, and Clear on each row; calendar export; a rail badge for overdue and due-today items; and a link from the board's Needs Attention. +3. **Publication check and fill blanks (#61).** User-initiated Crossref checks by DOI and title, optional ORCID works, review with Mark Published, Ignore, or Review later, and a previewed fill of empty fields only. Schema 7. +4. **Carry-over:** [#76](https://github.com/JUhalt/PaperRoute-Tracker/issues/76). +5. **Certification and release.** Preview, then Stable, using the v0.6 procedure: in-app updater only, an unlocked host for VM checks, and snapshots before each VM step. + +Slices 1–4 are complete in [PR #78](https://github.com/JUhalt/PaperRoute-Tracker/pull/78). v0.7.0-rc.1 is the Preview candidate; certification is tracked in the v0.7 release-certification issue. + --- # v0.8 Route Analytics & Reports diff --git a/UPGRADE_NOTES.md b/UPGRADE_NOTES.md index 2d920df..6dcd650 100644 --- a/UPGRADE_NOTES.md +++ b/UPGRADE_NOTES.md @@ -19,6 +19,20 @@ PaperRoute upgrades are designed not to overwrite the manuscript database, setti Use **Settings → Backup Library...** to create a portable backup. In v0.5 and earlier, it is **Data → Backup Library...**. +## Moving from v0.6 to v0.7 + +PaperRoute v0.7 replaces the Reminders page with **Deadlines** and adds the publication check. It upgrades the library to **Schema 7**, which remembers possible publications you have reviewed. + +Before upgrading, create a portable ZIP backup with v0.6 (**Settings → Backup Library...**) and keep it separately. + +The Schema 6-to-7 upgrade validates the existing library first and then changes only the schema marker, keeping the previous marker as `schema.v6.bak`. Manuscript and author data, managed files, and automatic backups stay byte-for-byte. If validation fails, nothing changes and PaperRoute says why. + +After upgrading: + +- Open **Deadlines** (Ctrl+4). Your revision deadlines, follow-ups, and reminders are all there, with work that has no date yet under **No date**. +- **Postpone** changes the date on the record that owns it, so the manuscript page shows the same date. +- v0.6 refuses to open a Schema 7 library. To go back, restore the backup you made with v0.6; changes made in v0.7 are not in it. Restore v0.7 backups with v0.7 or later. + ## Moving from v0.5 to v0.6 PaperRoute v0.6 changes where things live and how they look. **v0.6.0 is the current Stable release.** It does **not** migrate your data: the library stays on **Schema 6**, so v0.5 can still open a library that v0.6 has used, and portable backups made by either version restore in both. A backup before upgrading is still a sensible precaution. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 924caf1..2212478 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -16,7 +16,7 @@ If you only read one section, read this one. 6. Save each focused window, then choose **Save** in the manuscript page's **Unsaved changes** bar (or press **Ctrl+S**) to keep the complete workflow. 7. When the journal responds, open that submission and record the **Editorial Decision**. Revision decisions can carry a revision deadline. 8. Select that submission on the **Submissions** tab and open its **Reviewer Responses** tab to track individual comments, actions, and response drafts, then choose **Save** on the manuscript page. Keep original letters and revised files under the submission's correspondence. -9. Open **Reminders** in the left rail to see revision deadlines, journal follow-ups, and custom reminders in one place. +9. Open **Deadlines** in the left rail to see revision deadlines, journal follow-ups, your reminders, and unfinished submission preparation in one place. 10. Use **Settings > Backup Library...** before major changes or moving PaperRoute to another computer. PaperRoute does not require an account for its core workflow, and the manuscript-tracking database is stored locally. @@ -52,7 +52,7 @@ The stage describes the manuscript's current lifecycle position. Submission hist ### Finding your way around -PaperRoute opens in one window. The left rail lists its pages: **Board**, **Library**, **Journals**, **Reminders**, and **Import & Export**, with **Settings** and **Help** at the bottom. Press **Ctrl+1** to **Ctrl+5** to open the pages in that order, **Alt+Left** and **Alt+Right** to go back and forward, and **F1** for this guide. +PaperRoute opens in one window. The left rail lists its pages: **Board**, **Library**, **Journals**, **Deadlines**, and **Import & Export**, with **Settings** and **Help** at the bottom. Press **Ctrl+1** to **Ctrl+5** to open the pages in that order, **Alt+Left** and **Alt+Right** to go back and forward, and **F1** for this guide. For more room, choose the **«** button beside the PaperRoute name to collapse the rail to icons; hover over an icon to see its page, and choose **»** to expand the rail again. PaperRoute remembers your choice. Double-click the logo for **About PaperRoute**. @@ -65,7 +65,8 @@ Earlier versions reached these places through menus. Every command is still avai | Data > Backup Library..., Restore Backup... | **Settings** in the rail, and the **Import & Export** page | | Data > Authors & Affiliations... | **Library** page, **Authors & Affiliations** | | Data > Journal Library... | **Journals** page | -| Settings > Reminders & Calendar... | **Reminders** page | +| Settings > Reminders & Calendar... | **Deadlines** page | +| The **Reminders** page (v0.6) | **Deadlines** page | | Settings > Preferences..., Check for Updates..., Diagnostics..., About | **Settings** in the rail | | Settings > User Guide... and the Help button | **Help** in the rail, or **F1** | @@ -238,6 +239,28 @@ Crossref enrichment does **not** silently change manuscript stage, shelf/locatio External metadata should help fill a record, not take control of the research workflow. +### Check for publications + +Papers are sometimes published before you update PaperRoute. Choose **Check for Publications...** on the **Import & Export** page, or **⋯ > Check for Publication...** on a manuscript page, to ask whether work you are tracking has appeared. + +Manuscripts that have gone to a journal are checked by default; check or uncheck any others. PaperRoute then looks, one manuscript at a time: + +1. by the manuscript's **DOI**, when it now resolves to a published article; +2. by its **preprint DOI**, when Crossref links the preprint to its published version; +3. by **title**, in Crossref, allowing for case, punctuation, and an added subtitle; and +4. optionally, in an **ORCID record**: the iD of the author marked "This is me" is filled in for you. + +Each result reads "A publication matching this manuscript may have appeared" with **Review Match** (opens the article's page), **Mark Published...**, and **Ignore**. Results you leave stay on the **Deadlines** page under **No date** until you choose. + +- **Mark Published...** shows exactly what will change, then moves the manuscript to the Published shelf with the stage Published from the publication date, adds a history entry naming the journal and DOI, updates the journal if it differs, and fills empty publication fields. Fields that have a value, and your recorded submissions and decisions, are left as they are. +- **Ignore** remembers the answer, so later checks never show that match again. + +Nothing is checked in the background. If you are offline or Crossref asks PaperRoute to slow down, the check says which manuscripts it could not check and changes nothing. + +### Fill blanks from Crossref + +**Fill Blanks from Crossref...** on the **Import & Export** page looks up every manuscript that has a DOI and lists the empty fields Crossref can fill: journal, publisher, date, volume, issue, pages, URL, abstract, and keywords. Uncheck anything you do not want, then choose **Fill**. A field that already has a value is never listed or changed. + --- ## ORCID Public-Profile Import @@ -360,7 +383,7 @@ Each submission can contain: - optional follow-up date; and - notes. -The follow-up date appears on the **Reminders** page whenever it is explicitly set. PaperRoute treats it as a user-owned reminder, so recording an editorial decision does not silently remove it; clear or change the follow-up date when it is no longer useful. +The follow-up date appears on the **Deadlines** page whenever it is explicitly set. PaperRoute treats it as a user-owned reminder, so recording an editorial decision does not silently remove it; clear or change the follow-up date when it is no longer useful. ### Reusing the Journal Library @@ -439,43 +462,45 @@ Externally linked files remain references to their original paths. If those file --- -## Reminders and Calendar +## Deadlines -Open **Reminders** in the left rail. +Open **Deadlines** in the left rail, press **Ctrl+4**, or choose **View in Deadlines →** beside Needs Attention on the Board. The rail shows how many items are overdue or due today. -PaperRoute combines three reminder sources: +Deadlines lists everything that needs action, grouped by when: -1. **Revision deadlines** recorded in the manuscript workflow. -2. **Submission follow-up dates** explicitly recorded on journal submissions; these remain active until you clear or change them. -3. **Custom reminders** that you create yourself. +- **Overdue**, **Today**, **Next 7 days** (counted from today), and **Later** for dated items; +- **No date** for work that is under way without a date, such as a revision whose decision records no deadline, or a submission packet whose checklist still has required items open; +- **Done in the last 30 days**, folded away until you choose **Show**. -The reminder list shows due date, status, type, manuscript, reminder title, and journal where relevant. +Four kinds of item appear, each from the record that owns it: -### Reminder status +1. **Revisions**: the deadline on the latest editorial decision. A revision with reviewer comments shows how many are addressed. +2. **Follow-ups**: follow-up dates recorded on journal submissions. They remain until you clear or change them; recording a decision does not remove them. +3. **Reminders**: your own reminders. +4. **Preparation**: a submission packet not yet linked to a submission whose readiness checklist has required items open. -PaperRoute uses the local calendar date to classify reminders as: +The counts across the top ignore the filters. Use the chips to show one kind, or type in the filter box to narrow by manuscript, journal, or title. Clicking the active chip shows everything again. -- Overdue -- Due today -- Upcoming +### Acting on an item -The calculation is deterministic: the same stored dates and same "today" date produce the same status. +Each row has its main action as a button and the rest under **⋯**. With a row selected, **Enter** runs its first action and **Up**/**Down** move between rows. -### Custom reminders - -Use **Add Reminder...** on the Reminders page to create a manuscript-specific reminder. +| Item | Actions | +| --- | --- | +| Revision | **Open** (lands on the submission's reviewer responses), **Postpone...**; without a deadline, **Set Deadline...** | +| Follow-up | **Open** (lands on the submission), **Postpone...**, **Clear Follow-up...** | +| Reminder | **Done**, **Postpone...**, **Edit Reminder...**, **Open Manuscript** | +| Preparation | **Open Readiness** (the manuscript's Readiness & Packets tab) | -Custom reminders can be edited or marked complete from the same window. +**Postpone** offers tomorrow, one week, two weeks, or any date, and its button says exactly what will happen, for example **Postpone to Oct 4**. It changes the date on the record that owns the item (the editorial decision, the journal submission, or the reminder) and saves, so the manuscript page always shows the same date. Nothing is copied: Deadlines is a view of your records, not a second list to keep in sync. -Revision and follow-up reminders come from their source records. To change one of those dates, edit the editorial decision or journal submission that owns it. +**Add Reminder** creates a manuscript-specific reminder. ### Calendar export -Choose **Export Calendar (.ics)...** to create a portable iCalendar file containing active PaperRoute reminders. - -The `.ics` file can be imported into calendar software that supports iCalendar, including Outlook, Google Calendar, and Apple Calendar. +Choose **Export Calendar...** to save the dated items as a portable iCalendar (`.ics`) file. Undated work has no calendar date and is left out. -Calendar export does not change manuscript or reminder data. +The `.ics` file can be imported into calendar software that supports iCalendar, including Outlook, Google Calendar, and Apple Calendar. Export does not change your library. ### Windows notifications @@ -492,9 +517,9 @@ Important limitations: - PaperRoute does not run a hidden cloud reminder service. - If PaperRoute is not running, it cannot perform its startup reminder check. - Windows may suppress or change how notification balloons are displayed. -- A Windows notification failure never prevents PaperRoute from opening or using the Reminders page. +- A Windows notification failure never prevents PaperRoute from opening or using the Deadlines page. -The in-app reminder list is the authoritative reminder view. +The Deadlines page is the authoritative view. --- @@ -637,7 +662,7 @@ Use **Settings > Diagnostics...** when troubleshooting storage, environment, or 1. Choose **Add Manuscript**. 2. Enter the title, or choose **Paste a Title Page...** (see below). 3. Use Draft as the stage if active writing has begun. -4. Optionally check **Remind me** and name a first deadline. It becomes an ordinary custom reminder on the **Reminders** page. +4. Optionally check **Remind me** and name a first deadline. It becomes an ordinary reminder on the **Deadlines** page. 5. Choose **Add Manuscript**, then open the manuscript to add or adjust structured authors and a target journal. ### Paste a title page @@ -690,7 +715,7 @@ This records preparation only. Record the journal submission after it actually o Use either method: - Edit the journal submission and set a follow-up date; or -- open **Reminders** and add a custom reminder. +- open **Deadlines** and choose **Add Reminder**. An explicitly saved submission follow-up remains active until you clear or change it, even if an editorial decision is later recorded. @@ -703,7 +728,7 @@ An explicitly saved submission follow-up remains active until you clear or chang 5. Save. 6. Confirm the manuscript is in the Revision stage. -The deadline will then appear on the Reminders page and in the Needs Attention workflow as appropriate. +The deadline will then appear on the Deadlines page and in the Needs Attention workflow as appropriate. ## How do I record reviewer comments and my response? @@ -787,7 +812,7 @@ Visual Studio debugger sessions use the isolated development profile: Development managed-file copies are stored separately from the stable managed library. -External services are used only for explicit features such as Crossref metadata lookup, public ORCID lookup, GitHub update checks, or links you choose to open. +External services are used only for explicit features such as Crossref metadata lookup, a publication check you start, public ORCID lookup, GitHub update checks, or links you choose to open. A publication check sends Crossref the titles and DOIs of the manuscripts you check, and nothing else. PaperRoute does not require a PaperRoute account for the core manuscript library. @@ -821,7 +846,7 @@ https://example.org/path ### A reminder notification did not appear -Open **Reminders** first. If the reminder is present there, the stored reminder data is working. +Open **Deadlines** first. If the reminder is present there, the stored reminder data is working. Then check: @@ -841,7 +866,7 @@ PaperRoute ships a local copy of this guide. Open it with **Help** at the bottom The Route, Version History, readiness profiles, submission packets, and reviewer responses form one connected manuscript record. Later releases build on that record: -- **v0.6 — Workspace UI:** one main window with pages for the Board, Library, Journals, Reminders, and Import & Export; each manuscript opens as a tabbed page with a single Save / Discard step; a calmer card-based design; and faster Add Manuscript from a pasted title page. +- **v0.6 — Workspace UI:** one main window with pages for the Board, Library, Journals, Reminders (now Deadlines), and Import & Export; each manuscript opens as a tabbed page with a single Save / Discard step; a calmer card-based design; and faster Add Manuscript from a pasted title page. - **v0.7 — Deadline Center:** richer action and deadline management, plus user-initiated checks for publications and missing metadata. - **v0.8 — Route Analytics & Reports:** timing analytics, your history with each journal, printable route and pipeline-status reports, and work types and tags. - **v0.9 — 1.0 Hardening:** guided onboarding, accessibility, consistency, recovery, and release certification. diff --git a/docs/releases/0.7.0-rc.1.md b/docs/releases/0.7.0-rc.1.md new file mode 100644 index 0000000..87677dd --- /dev/null +++ b/docs/releases/0.7.0-rc.1.md @@ -0,0 +1,20 @@ +# PaperRoute Tracker v0.7.0-rc.1 — Deadline Center + +This Preview candidate replaces the Reminders page with **Deadlines**, one list of everything that needs action and when, and adds a publication check that runs only when you ask. v0.6.0 remains the current Stable release while installed, updater, and native display certification is completed. + +## Included + +- **Deadlines.** Revision deadlines, journal follow-ups, your reminders, unfinished submission preparation, and possible publications, grouped as Overdue, Today, Next 7 days, Later, No date, and Done. Counts across the top, chips for each kind, and a filter box. A revision shows how many reviewer comments are addressed. Work without a date stays visible instead of disappearing. +- **Act where you are.** Each row has its own actions: **Open** lands on the submission (at its reviewer responses for a revision), **Postpone...** offers tomorrow, a week, two weeks, or any date, **Done** completes a reminder, and **Clear Follow-up...** removes only the follow-up date. Every change is made on the record that owns the date, so Deadlines and the manuscript page always agree. +- **Always in view.** The rail's Deadlines badge counts what is overdue or due today, and **View in Deadlines →** sits beside Needs Attention on the Board. **Ctrl+4** opens Deadlines. +- **Check for Publications...** asks Crossref, and optionally an ORCID record, whether manuscripts you are tracking have been published: by DOI, by a preprint's link to its published version, or by title. Each possible match offers **Review Match**, **Mark Published...**, and **Ignore**. Nothing is checked in the background, and nothing changes until you choose. +- **Mark Published...** lists exactly what it will change before recording a normal lifecycle event. **Fill Blanks from Crossref...** fills empty publication fields after a preview and never replaces a value. +- **Fixes.** Revision deadlines name their journal. The first-deadline date in Add Manuscript shows in full at 150% scaling. + +## Before trying this candidate + +v0.7 upgrades the library to **Schema 7**, which remembers the possible publications you have reviewed. The upgrade validates your library and changes only the schema marker; manuscript data, managed files, and automatic backups stay byte-for-byte. **v0.6 cannot open a library after v0.7 has used it**, so make a portable backup with v0.6 first (**Settings → Backup Library...**) and keep it. The [upgrade notes](https://github.com/JUhalt/PaperRoute-Tracker/blob/master/UPGRADE_NOTES.md) explain the details. + +## Verification status + +Every automated test passes, with none skipped. Packaging, installed, updater, and native display checks are recorded in the v0.7 release-certification issue. This Preview does not claim Stable release certification.