65.9K
CodeProject 正在变化。 阅读更多。
Home

创建 OneNote Markdown 转换器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.33/5 (2投票s)

2021年11月18日

CPOL

8分钟阅读

viewsIcon

8377

在本系列文章中,我们将使用Graph API客户端通过一个微服务来消费OneNote文档,该微服务允许将文档转换为Markdown格式。

OneNote是一个用于创建笔记的出色工具,可以通过桌面应用程序或在线Office平台进行创建。这些笔记可以导出为Word或PDF文档,但许多企业需要其他格式的内容,例如Markdown。

让我们在此前的文章的基础上进行开发。我们将构建一个Spring Boot Web应用程序和微服务,使用Graph API客户端查询OneNote笔记本列表,显示笔记本页面的HTML预览,并将页面转换为Markdown并下载。我们将了解团队如何自动化转换内容的过程,而无需先将文件下载为Word文档然后将其转换为次要格式。

示例应用程序

您可以在GitHub上找到此示例应用程序的源代码。MSALOneNoteConverter存储库包含前端Web应用程序,而MSALOneNoteBackend存储库包含微服务。

构建后端微服务应用程序

我们将从后端微服务开始。它负责将笔记本列表返回给前端,并提供将笔记本页面从HTML转换为Markdown的功能。

引导Spring项目

我们将使用Spring Initializr生成初始应用程序模板,以创建Java Maven项目,该项目将使用Java 11的最新非快照版本的Spring生成JAR文件。

微服务需要以下依赖项

公开Graph API客户端

第一步是将Graph API客户端——使用我们在上一篇文章中创建的身份验证提供者进行配置——公开为一个Bean。我们将在以下包中的GraphClientConfiguration类中进行此操作。

package com.matthewcasperson.onenotebackend.configuration;

我们注入AADAuthenticationProperties类的一个实例。它提供了对Spring配置文件中值的访问,包括客户端ID、客户端密钥和租户ID。

  @Autowired
  AADAuthenticationProperties azureAd;

然后,我们使用上篇文章中创建的OboAuthenticationProvider创建一个Graph API客户端的实例。请注意,我们请求的令牌作用域是https://graph.microsoft.com/Notes.Read.All,授予我们对OneNote笔记的读取访问权限。

  @Bean
  public GraphServiceClient<Request> getClient() {
    return GraphServiceClient.builder()
        .authenticationProvider(new OboAuthenticationProvider(
            Set.of("https://graph.microsoft.com/Notes.Read.All"),
            azureAd.getTenantId(),
            azureAd.getClientId(),
            azureAd.getClientSecret()))
        .buildClient();
  }
}

配置Spring Security

我们通过AuthSecurityConfig类配置我们的微服务,使其所有请求都需要有效的令牌。

package com.matthewcasperson.onenotebackend.configuration;
 
import com.azure.spring.aad.webapi.AADResourceServerWebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
 
@EnableWebSecurity
public class AuthSecurityConfig extends AADResourceServerWebSecurityConfigurerAdapter {
 
  @Override
  protected void configure(final HttpSecurity http) throws Exception {
    super.configure(http);
    // @formatter:off
    http
        .authorizeRequests()
        .anyRequest()
        .authenticated();
    // @formatter:on
  }
}

添加转换库

我们的微服务将利用Pandoc在HTML和Markdown之间执行转换。Pandoc是一个开源文档转换器,我们将使用一个社区Java包装器库来调用它。

我们将以下依赖项添加到Maven的pom.xml文件中,以将Pandoc包装器包含到我们的项目中。

<dependency>
<groupId>org.bitbucket.leito</groupId>
<artifactId>universal-document-converter</artifactId>
<version>1.1.0</version>
</dependency>

请注意,包装器只是调用Pandoc可执行文件,因此Pandoc需要安装并可以在操作系统路径中找到。

添加Spring REST控制器

我们的微服务的大部分功能都在处理前端Web应用程序请求的REST控制器中。该控制器位于以下包中的OneNoteController类中。

package com.matthewcasperson.onenotebackend.controllers;

此类的工作量很大,让我们逐一分析。

我们首先注入Graph API客户端的实例。

@RestController
public class OneNoteController {
 
  @Autowired
  GraphServiceClient<Request> client;

我们的前端Web应用程序需要当前登录用户的笔记本列表。此列表由getNotes方法提供。

  @GetMapping("/notes")
  public List<String> getNotes() {
    return getNotebooks()
        .stream()
        .map(n -> n.displayName)
        .collect(Collectors.toList());
  }

为保持此示例应用程序的简单性,我们将提供查看和转换任何选定笔记本中第一个部分的第一页的功能。getNoteHtml方法提供页面的HTML。

  @GetMapping("/notes/{name}/html")
  public String getNoteHtml(@PathVariable("name") final String name) {
    return getPageHTML(name);
  }

除了页面HTML之外,我们的微服务还允许我们检索页面Markdown。Markdown内容由getNoteMarkdown方法返回。

  @GetMapping("/notes/{name}/markdown")
  public String getNoteMarkdown(@PathVariable("name") final String name) {
    final String content = getPageHTML(name);
    return convertContent(content);
  }

我们有几个私有方法来支持公共端点方法。这些私有方法负责查询Graph API和执行内容转换。

getPageHTML方法返回命名笔记本中第一个部分的第一页。

使用Graph API客户端时需要注意的一点是,许多方法可能返回null值。幸运的是,可能返回null的客户端方法已用@Nullable进行注释。这为IDE提供了必要的信息,以便在我们可能引用可能的null值时发出警告。

我们大量使用Optional类来避免代码中充斥null检查。

  private String getPageHTML(final String name) {
    return getNotebooks()
        .stream()
        // find the notebook that matches the supplied name
        .filter(n -> name.equals(n.displayName))
        // we only expect one notebook to match
        .findFirst()
        // get the notebook sections
        .map(notebook -> notebook.sections)
        // get the first page from the first section
        .map(sections -> getSectionPages(sections.getCurrentPage().get(0).id).get(0))
        // get the page id
        .map(page -> page.id)
        // get the content of the page
        .flatMap(this::getPageContent)
        // if any of the operations above returned null, return an error message
        .orElse("Could not load page content");
  }

HTML到Markdown的转换在convertContent方法中执行。我们使用DocumentConverter类公开的Pandoc包装器将原始页面HTML转换为Markdown。

请注意,DocumentConverter会构造要传递给外部Pandoc应用程序的参数,但本身不包含Pandoc应用程序。这意味着我们需要将Pandoc与我们的微服务一起安装。这也意味着我们通过外部文件传递数据,而不是直接传递字符串。

convertContent方法创建两个临时文件:第一个文件包含输入HTML,第二个文件用于输出Markdown。然后,它将这些文件传递给Pandoc,读取输出文件的内容,并清理所有内容。

要将笔记转换为不同的格式,可以编辑此方法以指定不同的Pandoc参数,或者完全替换它以将Pandoc作为转换工具。

  private String convertContent(final String html) {
    Path input = null;
    Path output = null;
 
    try {
      input = Files.createTempFile(null, ".html");
      output = Files.createTempFile(null, ".md");
 
      Files.write(input, html.getBytes());
 
      new DocumentConverter()
          .fromFile(input.toFile(), InputFormat.HTML)
          .toFile(output.toFile(), "markdown_strict-raw_html")
          .convert();
 
      return Files.readString(output);
    } catch (final IOException e) {
      // silently ignore
    } finally {
      try {
        if (input != null) {
          Files.delete(input);
        }
        if (output != null) {
          Files.delete(output);
        }
      } catch (final Exception ex) {
        // silently ignore
      }
    }

    return "There was an error converting the file";
  }

下一组方法负责调用Graph API。

getNotebooks方法检索当前登录用户创建的笔记本列表。

与Graph API交互时需要注意的一点是,它在请求父资源时通常不会返回子资源。但是,可以使用$expand查询参数覆盖此行为。在此,我们请求笔记本资源的列表并扩展它们的节。

  private List<Notebook> getNotebooks() {
    return Optional.ofNullable(client
            .me()
            .onenote()
            .notebooks()
            .buildRequest(new QueryOption("$expand", "sections"))
            .get())
        .map(BaseCollectionPage::getCurrentPage)
        .orElseGet(List::of);
  }

由于节不支持子页面的扩展,我们使用getSectionPages方法进行第二次请求,以返回与每个节关联的页面列表。

  private List<OnenotePage> getSectionPages(final String id) {
    return Optional.ofNullable(client
            .me()
            .onenote()
            .sections(id)
            .pages()
            .buildRequest()
            .get())
        .map(OnenotePageCollectionPage::getCurrentPage)
        .orElseGet(List::of);
  }

OnenotePage类不包含页面的内容。要访问内容,我们需要再进行一次API请求。

  private Optional<String> getPageContent(final String id) {
      return Optional.ofNullable(client
          .me()
          .onenote()
          .pages(id)
          .content()
          .buildRequest()
          .get())
          .map(s -> toString(s, null));
  }

toString方法将流转换为字符串并捕获任何异常,这使我们能够在lambda中执行此转换。受检异常与传递给Optional等类的lambda不太兼容。

  private String toString(final InputStream stream, final String defaultValue) {
    try (stream) {
      return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
    } catch (final IOException e) {
      return defaultValue;
    }
  }
}

构建前端Web应用程序

前端Web应用程序显示当前登录用户的笔记本列表,预览选定笔记本中第一个部分的第一个页面,并允许将页面下载为Markdown文件。

MSALOneNoteConverter存储库包含此部分的 कोड。

引导Spring项目

就像我们为后端所做的一样,我们将使用Spring Initializr生成初始应用程序模板,以创建Java Maven项目,该项目将使用Java 11的最新非快照版本的Spring生成JAR文件。

Web应用程序需要以下依赖项:

配置Spring Security

与微服务一样,我们的Web应用程序也通过AuthSecurityConfig类配置为要求对所有页面进行身份验证访问。

package com.matthewcasperson.onenote.configuration;
 
...
// imports
...

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class AuthSecurityConfig extends AADWebSecurityConfigurerAdapter {
 
    @Override
    protected void configure(final HttpSecurity http) throws Exception {
        super.configure(http);
        // @formatter:off
        http
            .authorizeRequests()
                .anyRequest().authenticated()
            .and()
                .csrf()
                .disable();
        // @formatter:on
    }
}

构建WebClient

我们需要一个WebClient以便前端应用程序与微服务进行交互。WebClient是用于进行HTTP调用的新的非阻塞解决方案,并且是比旧的RestTemplate更可取的选项。

要调用微服务,每个请求都必须有一个关联的访问令牌。WebClientConfig类配置WebClient实例,以包含来自OAuth2AuthorizedClient的令牌。

package com.matthewcasperson.onenote.configuration;

...
// imports
...

@Configuration
public class WebClientConfig {
  @Bean
  public OAuth2AuthorizedClientManager authorizedClientManager(
      final ClientRegistrationRepository clientRegistrationRepository,
      final OAuth2AuthorizedClientRepository authorizedClientRepository) {
 
    final OAuth2AuthorizedClientProvider authorizedClientProvider =
        OAuth2AuthorizedClientProviderBuilder.builder()
            .clientCredentials()
            .build();
 
    final DefaultOAuth2AuthorizedClientManager authorizedClientManager =
        new DefaultOAuth2AuthorizedClientManager(
            clientRegistrationRepository, authorizedClientRepository);
    authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
 
    return authorizedClientManager;
  }
 
  @Bean
  public static WebClient webClient(final OAuth2AuthorizedClientManager oAuth2AuthorizedClientManager) {
    final ServletOAuth2AuthorizedClientExchangeFilterFunction function =
        new ServletOAuth2AuthorizedClientExchangeFilterFunction(oAuth2AuthorizedClientManager);
    return WebClient.builder()
        .apply(function.oauth2Configuration())
        .build();
  }
}

构建MVC控制器

OneNoteController类中定义的MVC控制器公开了用户通过其Web浏览器访问的端点。我们将查看以下包中的代码。

package com.matthewcasperson.onenote.controllers;

让我们分解并检查这段代码。

我们注入由WebClientConfig类创建的WebClient的实例。

@Controller
public class OneNoteController {
 
  @Autowired
  WebClient webClient;

getIndex方法接收一个配置为访问微服务的OAuth2AuthorizedClient。此客户端被传递给WebClient以检索当前登录用户创建的笔记本列表。生成的列表被保存为模型属性notes

  @GetMapping("/")
  public ModelAndView getIndex(
      @RegisteredOAuth2AuthorizedClient("api") final OAuth2AuthorizedClient client) {
    final List notes = webClient
        .get()
        .uri("https://:8081/notes/")
        .attributes(oauth2AuthorizedClient(client))
        .retrieve()
        .bodyToMono(List.class)
        .block();
 
    final ModelAndView mav = new ModelAndView("index");
    mav.addObject("notes", notes);
    return mav;
  }

getPageView方法捕获两个路径,允许以HTML形式预览选定的笔记本,并将其下载为Markdown。

iframesrc模型属性是一个指向返回笔记本页面为HTML的端点的路径。markdownsrc模型属性是一个指向提供页面为可下载Markdown文件的端点的路径。

  @GetMapping("/notes/{name}")
  public ModelAndView getPageView(@PathVariable("name") final String name) {
    final ModelAndView mav = new ModelAndView("pageview");
    mav.addObject("iframesrc", "/notes/" + name + "/html");
    mav.addObject("markdownsrc", "/notes/" + name + "/markdown");
    return mav;
  }

为了预览笔记本页面的HTML,getNoteHtml方法返回原始HTML,以及X-Frame-OptionsContent-Security-Policy标头,这些标头允许在HTML iframe元素中查看此端点。

  @GetMapping(value = "/notes/{name}/html", produces = MediaType.TEXT_HTML_VALUE)
  @ResponseBody
  public String getNoteHtml(
      @RegisteredOAuth2AuthorizedClient("api") final OAuth2AuthorizedClient client,
      @PathVariable("name") final String name,
      final HttpServletResponse response) {
    response.setHeader("X-Frame-Options", "SAMEORIGIN");
    response.setHeader("Content-Security-Policy", " frame-ancestors 'self'");
    return webClient
        .get()
        .uri("https://:8081/notes/" + name + "/html")
        .attributes(oauth2AuthorizedClient(client))
        .retrieve()
        .bodyToMono(String.class)
        .block();
  }

getNoteMarkdown方法将页面提供为可下载的Markdown文件。通过返回ResponseEntity对象并定义Content-TypeContent-Disposition标头,我们指示浏览器下载返回的内容而不是在浏览器中显示。

  @GetMapping("/notes/{name}/markdown")
  public ResponseEntity<byte[]> getNoteMarkdown(
      @RegisteredOAuth2AuthorizedClient("api") final OAuth2AuthorizedClient client,
      @PathVariable("name") final String name) {
    final String markdown = webClient
        .get()
        .uri("https://:8081/notes/" + name + "/markdown")
        .attributes(oauth2AuthorizedClient(client))
        .retrieve()
        .bodyToMono(String.class)
        .block();
 
    final HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.TEXT_MARKDOWN);
    final String filename = "page.md";
    headers.setContentDispositionFormData(filename, filename);
    return new ResponseEntity<>(markdown.getBytes(), headers, HttpStatus.OK);
  }
}

创建Thymeleaf模板

index.html页面显示笔记本列表,并提供一个按钮将浏览器重定向到下一页。

<html>
<head>
  <link rel="stylesheet" href="/style.css">
  <script>
    function handleClick() {
      if (note.selectedIndex !== -1) {
        location.href='/notes/' + note.options[note.selectedIndex].value;
      } else {
        alert("Please select a notebook");
      }
    }
  </script>
</head>
<body>
<div class="container">
  <div class="header">
    <div class="title"><a href="/">ONENOTE CONVERTER</a></div>
  </div>
  <div class="main">
    <form class="formContainer">
      <div class="formRow">
        <select style="display: block" size="5" id="note">
          <option th:each="note: ${notes}" th:value="${note}" th:text="${note}">
          </option>
        </select>
      </div>
      <div class="formRow">
        <input type="button" value="View Note" onclick="handleClick();">
      </div>
    </form>
  </div>
</div>
</body>
</html>

pageview.html页面在iframe中显示页面的HTML,并提供一个表单按钮来下载Markdown文件。

<html>
<head>
  <link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="container">
  <div class="header">
    <div class="title"><a href="/">ONENOTE CONVERTER</a></div>
  </div>
  <div class="main">
    <form class="formContainer">
      <div class="formRow">
        <iframe style="width: 100%; height: 400px" th:src="${iframesrc}"></iframe>
      </div>
      <div class="formRow">
        <form style="margin-top: 10px" th:action="${markdownsrc}">
          <input type="submit" value="Download Markdown" />
        </form>
      </div>
    </form>
  </div>
</div>
</body>
</html>

结论

通过利用Graph API客户端,我们可以使用流畅且类型安全的接口与Microsoft Graph API进行交互。这比执行原始HTTP请求和处理返回的JSON要方便可靠得多。

在本篇文章中,我们使用Graph API客户端检索OneNote笔记本页面,预览原始页面HTML,并提供下载页面Markdown版本的功能。虽然这是一个相对简单的示例,但它演示了Spring Boot应用程序如何通过使用Microsoft Graph API和Azure AD,代表最终用户与Microsoft Office文档无缝交互。

在本系列的最后一篇文章中,我们将介绍如何将Spring与Microsoft Teams集成,创建一个简单的事件管理机器人。

© . All rights reserved.