diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..fe1152b
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,30 @@
+**/.classpath
+**/.dockerignore
+**/.env
+**/.git
+**/.gitignore
+**/.project
+**/.settings
+**/.toolstarget
+**/.vs
+**/.vscode
+**/*.*proj.user
+**/*.dbmdl
+**/*.jfm
+**/azds.yaml
+**/bin
+**/charts
+**/docker-compose*
+**/Dockerfile*
+**/node_modules
+**/npm-debug.log
+**/obj
+**/secrets.dev.yaml
+**/values.dev.yaml
+LICENSE
+README.md
+!**/.gitignore
+!.git/HEAD
+!.git/config
+!.git/packed-refs
+!.git/refs/heads/**
\ No newline at end of file
diff --git a/.env b/.env
new file mode 100644
index 0000000..f6ae4cb
--- /dev/null
+++ b/.env
@@ -0,0 +1,2 @@
+PAPERLESS_TOKEN=cfa320a52bec92cc7bef74415238ee000fc42a3c
+PAPERLESS_API_URI=https://paperless.camcass.ca/api/
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..1908a17
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,32 @@
+# Stage 1: Runtime
+FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
+WORKDIR /app
+EXPOSE 8080
+
+# Stage 2: Build
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+WORKDIR /src
+
+# Copy both .csproj files to restore dependencies first (better for Docker caching)
+COPY ["paperless-ngx-export.service/paperless-ngx-export.service.csproj", "paperless-ngx-export.service/"]
+COPY ["paperless-ngx-export/paperless-ngx-export.csproj", "paperless-ngx-export/"]
+
+# Restore only the entry point project (it will automatically restore the dependency)
+RUN dotnet restore "paperless-ngx-export.service/paperless-ngx-export.service.csproj"
+
+# Copy the rest of the source code for both projects
+COPY . .
+
+# Build the service
+WORKDIR "/src/paperless-ngx-export.service"
+RUN dotnet build "paperless-ngx-export.service.csproj" -c Release -o /app/build
+
+# Stage 3: Publish
+FROM build AS publish
+RUN dotnet publish "paperless-ngx-export.service.csproj" -c Release -o /app/publish /p:UseAppHost=false
+
+# Stage 4: Final Image
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "paperless-ngx-export.service.dll"]
\ No newline at end of file
diff --git a/Paperness-ngx-export.slnx b/Paperness-ngx-export.slnx
new file mode 100644
index 0000000..19352e2
--- /dev/null
+++ b/Paperness-ngx-export.slnx
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/docker-build-image.sh b/docker-build-image.sh
new file mode 100644
index 0000000..4d1acd2
--- /dev/null
+++ b/docker-build-image.sh
@@ -0,0 +1 @@
+docker build -t paperless-ngx-export-api .
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..c959944
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,9 @@
+services:
+ paperless-ngx-export-api-service:
+ image: paperless-ngx-export-api
+ container_name: paperless-ngx-export-api-service
+ ports:
+ - "8081:8080"
+ env_file:
+ - .env
+ restart: unless-stopped
\ No newline at end of file
diff --git a/paperless-ngx-export.service/Dockerfile b/paperless-ngx-export.service/Dockerfile
new file mode 100644
index 0000000..1dbf71c
--- /dev/null
+++ b/paperless-ngx-export.service/Dockerfile
@@ -0,0 +1,28 @@
+# Use the official .NET 10 ASP.NET runtime
+FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
+WORKDIR /app
+EXPOSE 8080
+
+# Use the .NET 10 SDK for building
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+WORKDIR /src
+
+# COPY both project files to restore dependencies
+COPY ["paperless-ngx-export.service/paperless-ngx-export.service.csproj", "paperless-ngx-export.service/"]
+COPY ["paperless-ngx-export/paperless-ngx-export.csproj", "paperless-ngx-export/"]
+
+# Restore based on the main project
+RUN dotnet restore "paperless-ngx-export.service/paperless-ngx-export.service.csproj"
+
+# Copy everything else and build
+COPY . .
+WORKDIR "/src/paperless-ngx-export.service"
+RUN dotnet build "paperless-ngx-export.service.csproj" -c Release -o /app/build
+
+FROM build AS publish
+RUN dotnet publish "paperless-ngx-export.service.csproj" -c Release -o /app/publish /p:UseAppHost=false
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "paperless-ngx-export.service.dll"]
\ No newline at end of file
diff --git a/paperless-ngx-export.service/Program.cs b/paperless-ngx-export.service/Program.cs
new file mode 100644
index 0000000..ec684f1
--- /dev/null
+++ b/paperless-ngx-export.service/Program.cs
@@ -0,0 +1,80 @@
+using Microsoft.AspNetCore.Mvc;
+using paperless_ngx_export;
+using System.IO;
+using System.Reflection.Metadata.Ecma335;
+using System.Text;
+
+namespace paperless_ngx_export.service
+{
+ public class Program
+ {
+ public static void Main(string[] args)
+ {
+ var builder = WebApplication.CreateBuilder(args);
+ var app = builder.Build();
+
+ app.MapGet("/", (IConfiguration config) =>
+#if DEBUG
+{
+ return Results.Content("
Calendar
Spreadsheet", "text/html");
+ }
+#else
+ "API"
+#endif
+ );
+
+ app.MapGet("/calendar", async (IConfiguration config) =>
+ {
+ // Retrieve the environment variable
+ string? apiToken = config["PAPERLESS_TOKEN"];
+ string? apiBase = config["PAPERLESS_API_URI"];
+
+ if (apiToken == null)
+ {
+ return Results.Problem("PAPERLESS_TOKEN is missing from the environment.");
+ }
+ if (apiBase== null)
+ {
+ return Results.Problem("PAPERLESS_API_URI is missing from the environment.");
+ }
+
+ api.init(apiToken!, apiBase!);
+
+ var caldav = await api.getExpirationDatesCalDAVAsync();
+ return Results.Content(caldav, "text/calendar", Encoding.UTF8);
+ });
+
+ app.MapGet("/spreadsheet", async (IConfiguration config) =>
+ {
+#if DEBUG
+ string? apiToken = "cfa320a52bec92cc7bef74415238ee000fc42a3c";
+ string? apiBase = "https://paperless.camcass.ca/api/";
+#else
+ // Retrieve the environment variable
+ string? apiToken = config["PAPERLESS_TOKEN"];
+ string? apiBase = config["PAPERLESS_API_URI"];
+#endif
+
+ if (apiToken == null)
+ {
+ return Results.Problem("PAPERLESS_TOKEN is missing from the environment.");
+ }
+ if (apiBase == null)
+ {
+ return Results.Problem("PAPERLESS_API_URI is missing from the environment.");
+ }
+
+ api.init(apiToken!, apiBase!);
+
+ var content = await spreadsheet.GetSummarySpreadsheetAsByteArrayAsync();
+
+ return Results.File(
+ content,
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "Report.xlsx");
+ });
+
+ app.Run();
+ }
+ }
+}
\ No newline at end of file
diff --git a/paperless-ngx-export.service/Properties/launchSettings.json b/paperless-ngx-export.service/Properties/launchSettings.json
new file mode 100644
index 0000000..bfab8a3
--- /dev/null
+++ b/paperless-ngx-export.service/Properties/launchSettings.json
@@ -0,0 +1,24 @@
+{
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "dotnetRunMessages": true,
+ "applicationUrl": "http://localhost:5066"
+ },
+ "Container (Dockerfile)": {
+ "commandName": "Docker",
+ "launchBrowser": true,
+ "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
+ "environmentVariables": {
+ "ASPNETCORE_HTTP_PORTS": "8080"
+ },
+ "publishAllPorts": true,
+ "useSSL": false
+ }
+ },
+ "$schema": "https://json.schemastore.org/launchsettings.json"
+}
\ No newline at end of file
diff --git a/paperless-ngx-export.service/appsettings.Development.json b/paperless-ngx-export.service/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/paperless-ngx-export.service/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/paperless-ngx-export.service/appsettings.json b/paperless-ngx-export.service/appsettings.json
new file mode 100644
index 0000000..10f68b8
--- /dev/null
+++ b/paperless-ngx-export.service/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/paperless-ngx-export.service/docker-compose.yml b/paperless-ngx-export.service/docker-compose.yml
new file mode 100644
index 0000000..000303f
--- /dev/null
+++ b/paperless-ngx-export.service/docker-compose.yml
@@ -0,0 +1,9 @@
+services:
+ paperless-ngx-export-api-service:
+ image: paperless-ngx-export-api
+ ports:
+ - "8082:8080"
+ environment:
+ - PAPERLESS_TOKEN=cfa320a52bec92cc7bef74415238ee000fc42a3c
+ - PAPERLESS_API_URI=https://paperless.camcass.ca/api/
+ restart: unless-stopped
\ No newline at end of file
diff --git a/paperless-ngx-export.service/paperless-ngx-export.service.csproj b/paperless-ngx-export.service/paperless-ngx-export.service.csproj
new file mode 100644
index 0000000..07f1433
--- /dev/null
+++ b/paperless-ngx-export.service/paperless-ngx-export.service.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net10.0
+ enable
+ enable
+ paperness_ngx_export.service
+ Linux
+
+
+
+
+
+
+
+
+
+
+
diff --git a/paperless-ngx-export.terminal/TerminalProgram.cs b/paperless-ngx-export.terminal/TerminalProgram.cs
new file mode 100644
index 0000000..fb9da90
--- /dev/null
+++ b/paperless-ngx-export.terminal/TerminalProgram.cs
@@ -0,0 +1,21 @@
+namespace paperless_ngx_export.terminal
+{
+ internal class TerminalProgram
+ {
+ static void Main(string[] args)
+ {
+ api.init("cfa320a52bec92cc7bef74415238ee000fc42a3c", "https://paperless.camcass.ca/api/");
+
+ var outputString = api.getExpirationDatesCalDAVAsync().Result;
+
+ using (var writer = new StreamWriter("Z:\\paperless_expirationdates.ics", false))
+ {
+ writer.Write(outputString);
+ writer.Flush();
+ writer.Close();
+ }
+
+ Console.WriteLine("Hello, World!");
+ }
+ }
+}
diff --git a/paperless-ngx-export.terminal/paperless-ngx-export.terminal.csproj b/paperless-ngx-export.terminal/paperless-ngx-export.terminal.csproj
new file mode 100644
index 0000000..12d5db5
--- /dev/null
+++ b/paperless-ngx-export.terminal/paperless-ngx-export.terminal.csproj
@@ -0,0 +1,15 @@
+
+
+
+ Exe
+ net10.0
+ paperless_ngx_export.terminal
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/paperless-ngx-export/Models/Correspondent.cs b/paperless-ngx-export/Models/Correspondent.cs
new file mode 100644
index 0000000..c899a59
--- /dev/null
+++ b/paperless-ngx-export/Models/Correspondent.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace paperless_ngx_export.Models
+{
+ public class Correspondent : ResultBase
+ {
+ public string slug { get; set; }
+ public string name { get; set; }
+ public int document_count { get; set; }
+
+ public override string ToString()
+ {
+ return name;
+ }
+ }
+}
\ No newline at end of file
diff --git a/paperless-ngx-export/Models/CustomField.cs b/paperless-ngx-export/Models/CustomField.cs
new file mode 100644
index 0000000..5eae8f1
--- /dev/null
+++ b/paperless-ngx-export/Models/CustomField.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace paperless_ngx_export.Models
+{
+ public enum CustomField_DataType
+ {
+ select,
+ date,
+ @string
+ }
+
+ public class CustomField_ExtraData
+ {
+ public CustomField_Select_Option[] select_options { get; set; }
+ public string? default_currency { get; set; }
+ }
+
+ public class CustomField_Select_Option
+ {
+ public string id { get; set; }
+ public string label { get; set; }
+ public override string ToString()
+ {
+ return $"{label} ({id})";
+ }
+ }
+
+ public class CustomField : ResultBase
+ {
+ public string name { get; set; }
+ public CustomField_DataType data_type { get; set; }
+ public int? document_count { get; set; }
+
+ public CustomField_ExtraData? extra_data { get; set; }
+
+ public override string ToString()
+ {
+ return $"{name} ({data_type})";
+ }
+ }
+}
\ No newline at end of file
diff --git a/paperless-ngx-export/Models/CustomFieldKeyValuePair.cs b/paperless-ngx-export/Models/CustomFieldKeyValuePair.cs
new file mode 100644
index 0000000..2d92047
--- /dev/null
+++ b/paperless-ngx-export/Models/CustomFieldKeyValuePair.cs
@@ -0,0 +1,17 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace paperless_ngx_export.Models
+{
+ public class CustomFieldKeyValuePair
+ {
+ public int field { get; set; }
+ public string value { get; set; }
+
+ public override string ToString()
+ {
+ return $"{field}: {value}";
+ }
+ }
+}
diff --git a/paperless-ngx-export/Models/CustomFieldParsed.cs b/paperless-ngx-export/Models/CustomFieldParsed.cs
new file mode 100644
index 0000000..6be74fd
--- /dev/null
+++ b/paperless-ngx-export/Models/CustomFieldParsed.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace paperless_ngx_export.Models
+{
+ public class CustomFieldParsed
+ {
+ public string Name { get; set; }
+ public object? Value { get; set; }
+ public Type DataType { get; set; }
+
+ public string StringValue => (string)Value;
+
+ public DateTime DateValue => Value != null && DataType == typeof(DateTime) ? (DateTime)Value : DateTime.MinValue;
+
+ public static CustomFieldParsed FromCustomFieldKeyValuePair(CustomFieldKeyValuePair pair, IEnumerable customFields)
+ {
+ if (pair.value != null && customFields.FirstOrDefault(cf => cf.Id == pair.field) is CustomField field)
+ {
+ try
+ {
+ switch (field.data_type)
+ {
+ case CustomField_DataType.date:
+ return new CustomFieldParsed
+ {
+ Name = field.name,
+ Value = DateTime.Parse(pair.value),
+ DataType = typeof(DateTime)
+ };
+ break;
+ case CustomField_DataType.select:
+ return new CustomFieldParsed
+ {
+ Name = field.name,
+ Value = field.extra_data.select_options.First(so => so.id == pair.value).label,
+ DataType = typeof(string)
+ };
+ break;
+ default:
+ return new CustomFieldParsed
+ {
+ Name = field.name,
+ Value = pair.value,
+ DataType = typeof(string)
+ };
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ ;
+ }
+ }
+
+
+ return null;
+ }
+
+ public override string ToString()
+ {
+ return $"{Name}: {Value} ({DataType.Name})";
+ }
+ }
+}
\ No newline at end of file
diff --git a/paperless-ngx-export/Models/Document.cs b/paperless-ngx-export/Models/Document.cs
new file mode 100644
index 0000000..fb04442
--- /dev/null
+++ b/paperless-ngx-export/Models/Document.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace paperless_ngx_export.Models
+{
+ public class Document : ResultBase
+ {
+ public int? correspondent { get; set; }
+ public int? document_type { get; set; }
+ public int? storage_path { get; set; }
+ public string title { get; set; }
+ public string content { get; set; }
+ public int[] tags { get; set; }
+ public DateTime created { get; set; }
+ public DateTime created_date { get; set; }
+ public DateTimeOffset modified { get; set; }
+ public DateTimeOffset added { get; set; }
+ public DateTimeOffset? deleted_at { get; set; }
+ public string? archive_serial_number { get; set; }
+ public string original_file_name { get; set; }
+ public string archived_file_name { get; set; }
+ public int owner { get; set; }
+ public string[] notes { get; set; }
+ public CustomFieldKeyValuePair[] custom_fields { get; set; }
+ public int? page_count { get; set; }
+ public string mime_type { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/paperless-ngx-export/Models/DocumentParsed.cs b/paperless-ngx-export/Models/DocumentParsed.cs
new file mode 100644
index 0000000..6414e18
--- /dev/null
+++ b/paperless-ngx-export/Models/DocumentParsed.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace paperless_ngx_export.Models
+{
+ public class DocumentParsed
+ {
+ #region static arrays
+ public static Tag[] _tags { get; set; } = new Tag[0];
+ public static Correspondent[] _correspondents { get; set; } = new Correspondent[0];
+ public static CustomField[] _customFields { get; set; } = new CustomField[0];
+ public static DocumentType[] _documentTypes { get; set; } = new DocumentType[0];
+ #endregion
+
+ public string Correspondent { get; protected set; }
+ public string DocumentType { get; protected set; } = "Unsorted";
+ public string Title { get; protected set; }
+ public string Content { get; protected set; }
+ public List Tags { get; protected set; } = new List();
+ public DateTime Created { get; protected set; }
+ public DateTimeOffset Modified { get; protected set; }
+ public DateTimeOffset Added { get; protected set; }
+ public DateTimeOffset? DeletedAt { get; protected set; }
+ public List Notes{ get; protected set; }=new List();
+ public string MIMEType { get; protected set; }
+ public int? PageCount { get; protected set; }
+ public List CustomFields { get; protected set; } = new List();
+
+ public override string ToString()
+ {
+ return $"{Correspondent} {DocumentType}";
+ }
+
+ public static DocumentParsed FromDocument(Document doc)
+ {
+ DocumentParsed d = new DocumentParsed();
+
+ d.Correspondent = _correspondents.First(c => c.Id == doc.correspondent).name;
+ d.DocumentType = _documentTypes.FirstOrDefault(dt => dt.Id == doc.document_type)?.name ?? "Unsorted";
+ d.Title=doc.title.Trim();
+ d.Content = doc.content.Trim();
+ _tags.Where(_t=>doc.tags.Any(dt=>_t.Id == dt)).ToList().ForEach(t => d.Tags.Add(t));
+ d.Created=doc.created;
+ d.Modified=doc.modified;
+ d.Added=doc.added;
+ d.DeletedAt = doc.deleted_at;
+ d.Notes = doc.notes.ToList(); ;
+ d.MIMEType = doc.mime_type;
+ d.PageCount = doc.page_count;
+ _customFields.Where(_cf => doc.custom_fields.Any(dcf => _cf.Id == dcf.field)).ToList()
+ .ForEach(cf =>
+ {
+ var cfp = CustomFieldParsed.FromCustomFieldKeyValuePair(
+ doc.custom_fields.First(dcf => dcf.field == cf.Id),
+ _customFields);
+ if (!(cfp is null))
+ {
+ d.CustomFields.Add(cfp);
+ }
+ });
+ return d;
+ }
+ }
+}
\ No newline at end of file
diff --git a/paperless-ngx-export/Models/DocumentType.cs b/paperless-ngx-export/Models/DocumentType.cs
new file mode 100644
index 0000000..9db65b9
--- /dev/null
+++ b/paperless-ngx-export/Models/DocumentType.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace paperless_ngx_export.Models
+{
+ public class DocumentType : ResultBase
+ {
+ public string slug { get; set; }
+ public string name { get; set; }
+ public int? document_count { get; set; }
+
+ public override string ToString()
+ {
+ return name;
+ }
+ }
+}
\ No newline at end of file
diff --git a/paperless-ngx-export/Models/ResultBase.cs b/paperless-ngx-export/Models/ResultBase.cs
new file mode 100644
index 0000000..39aafdd
--- /dev/null
+++ b/paperless-ngx-export/Models/ResultBase.cs
@@ -0,0 +1,11 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace paperless_ngx_export.Models
+{
+ public class ResultBase
+ {
+ public int Id { get; set; }
+ }
+}
diff --git a/paperless-ngx-export/Models/RootObject.cs b/paperless-ngx-export/Models/RootObject.cs
new file mode 100644
index 0000000..88d203a
--- /dev/null
+++ b/paperless-ngx-export/Models/RootObject.cs
@@ -0,0 +1,12 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace paperless_ngx_export.Models
+{
+ internal class RootObject where T : ResultBase
+ {
+ public int Count { get; set; }
+ public List Results { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/paperless-ngx-export/Models/Tag.cs b/paperless-ngx-export/Models/Tag.cs
new file mode 100644
index 0000000..67e71be
--- /dev/null
+++ b/paperless-ngx-export/Models/Tag.cs
@@ -0,0 +1,19 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace paperless_ngx_export.Models
+{
+ public class Tag : ResultBase
+ {
+ public string slug { get; set; }
+ public string name { get; set; }
+ public string color { get; set; }
+ public string text_color { get; set; }
+
+ public override string ToString()
+ {
+ return $"{name} ({Id})";
+ }
+ }
+}
\ No newline at end of file
diff --git a/paperless-ngx-export/api.cs b/paperless-ngx-export/api.cs
new file mode 100644
index 0000000..3d4636a
--- /dev/null
+++ b/paperless-ngx-export/api.cs
@@ -0,0 +1,310 @@
+using Newtonsoft.Json;
+using paperless_ngx_export.Models;
+using System;
+using System.Collections.Generic;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json.Nodes;
+
+namespace paperless_ngx_export
+{
+ public static class api
+ {
+ public static bool isInitialized { get; private set; } = false;
+ static string paperless_api_base;
+ static string token;
+
+ public static void init(string token, string paperless_api_base)
+ {
+ api.paperless_api_base = paperless_api_base.Trim();
+ setToken(token.Trim());
+
+ isInitialized = !string.IsNullOrWhiteSpace(api.paperless_api_base) && !string.IsNullOrEmpty(api.token);
+ }
+
+ private static void setToken(string token)
+ {
+ api.token = token.Trim().Trim('/');
+ }
+
+ private static HttpRequestMessage getHttpRequestMessage(string query, int page = -1)
+ {
+ if (page > 0)
+ {
+ var pagedQuery = query.Contains('?') ? $"{query}&page={page}" : $"{query}?page={page}";
+ return getHttpRequestMessage(pagedQuery, HttpMethod.Get);
+ }
+ else
+ return getHttpRequestMessage(query, HttpMethod.Get);
+ }
+
+ private static HttpRequestMessage getHttpRequestMessage(string query, HttpMethod httpMethod)
+ {
+ HttpRequestMessage message = new HttpRequestMessage(httpMethod, $"{api.paperless_api_base}{query}");
+ //message.Headers.Authorization = getAuthenticationHeaderValue();
+ message.Headers.Add("Authorization", $"Token {api.token}");
+
+ return message;
+ }
+
+ private static AuthenticationHeaderValue getAuthenticationHeaderValue()
+ {
+ var header = new AuthenticationHeaderValue("Token", api.token);
+ return header;
+ }
+
+ public static async Task> getTagsAsync()
+ {
+ var query = "tags/";
+ var tags = new List();
+ if (!isInitialized) { throw new NotSupportedException("API base URL or token not initialized"); }
+ using (HttpClient client = new HttpClient())
+ {
+ var count = -1;
+ var page = 1;
+ while (count != 0)
+ {
+ count = 0;
+ var response = await client.SendAsync(getHttpRequestMessage(query, page));
+ if (response != null && response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ var root = JsonConvert.DeserializeObject>(responseString);
+ if (root.Results.Any())
+ {
+ count = root.Results.Count();
+ tags.AddRange(root.Results);
+ }
+ }
+ page++;
+ }
+ }
+
+ return tags;
+ }
+
+ public static async Task> getCurrentDocumentsAsync(bool includeNonExpirableDocuments = true)
+ {
+ var query = "documents/";
+ var excludedTagIds = (await getTagsAsync()).Where(x => (includeNonExpirableDocuments && x.name == "No Expiration") || x.name == "Action:Renewed" || x.name == "HR:Inactive").Select(x => x.Id).ToList();
+ excludedTagIds.ForEach(id => query += query.Contains('?') ? $"&tags__id__none={id}" : $"?tags__id__none={id}");
+
+ var documents = new List();
+ if (!isInitialized) { throw new NotSupportedException("API base URL or token not initialized"); }
+ using (HttpClient client = new HttpClient())
+ {
+ var count = -1;
+ var page = 1;
+ while (count != 0)
+ {
+ count = 0;
+ var response = await client.SendAsync(getHttpRequestMessage(query, page));
+ if (response != null && response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ var root = JsonConvert.DeserializeObject>(responseString);
+ if (root.Results.Any())
+ {
+ count = root.Results.Count();
+ foreach (var document in root.Results)
+ {
+ bool isValid = true;
+ foreach (var t in document.tags)
+ if (excludedTagIds.Contains(t))
+ isValid = false;
+
+ if(isValid)
+ documents.Add(document);
+ }
+ }
+ }
+ page++;
+ }
+ }
+
+ return documents.OrderBy(d=>d.correspondent).ToList();
+ }
+
+ public static async Task> getAllDocumentsAsync()
+ {
+ var query = "documents/";
+ var documents = new List();
+ if (!isInitialized) { throw new NotSupportedException("API base URL or token not initialized"); }
+ using (HttpClient client = new HttpClient())
+ {
+ var count = -1;
+ var page = 1;
+ while (count != 0)
+ {
+ count = 0;
+ var response = await client.SendAsync(getHttpRequestMessage(query, page));
+ if (response != null && response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ var root = JsonConvert.DeserializeObject>(responseString);
+ if (root.Results.Any())
+ {
+ count = root.Results.Count();
+ documents.AddRange(root.Results);
+ }
+ }
+ page++;
+ }
+ }
+
+ return documents;
+ }
+
+ public static async Task> getCorrespondentsAsync()
+ {
+ var query = "correspondents/";
+ if (!isInitialized) { throw new NotSupportedException("API base URL or token not initialized"); }
+ var correspondents = new List();
+ using (HttpClient client = new HttpClient())
+ {
+ var count = -1;
+ var page = 1;
+ while (count != 0)
+ {
+ count = 0;
+ var response = await client.SendAsync(getHttpRequestMessage(query, page));
+ if (response != null && response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ var root = JsonConvert.DeserializeObject>(responseString);
+ if (root.Results.Any())
+ {
+ count = root.Results.Count();
+
+ correspondents.AddRange(root.Results);
+ }
+ }
+ page++;
+ }
+ }
+
+ return correspondents;
+ }
+
+ public static async Task> getCustomFieldsAsync()
+ {
+ var query = "custom_fields/";
+ var fields = new List();
+ if (!isInitialized) { throw new NotSupportedException("API base URL or token not initialized"); }
+ using (HttpClient client = new HttpClient())
+ {
+ var count = -1;
+ var page = 1;
+ while (count != 0)
+ {
+ count = 0;
+ var response = await client.SendAsync(getHttpRequestMessage(query, page));
+ if (response != null && response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ var root = JsonConvert.DeserializeObject>(responseString);
+ if (root.Results.Any())
+ {
+ count = root.Results.Count();
+ fields.AddRange(root.Results);
+ }
+ }
+ page++;
+ }
+ }
+
+ return fields;
+ }
+
+ public static async Task> getDocumentTypesAsync()
+ {
+ var query = "document_types/";
+ var doctypes = new List();
+ if (!isInitialized) { throw new NotSupportedException("API base URL or token not initialized"); }
+ using (HttpClient client = new HttpClient())
+ {
+ var count = -1;
+ var page = 1;
+ while (count != 0)
+ {
+ count = 0;
+ var response = await client.SendAsync(getHttpRequestMessage(query, page));
+ if (response != null && response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ var root = JsonConvert.DeserializeObject>(responseString);
+ if (root.Results.Any())
+ {
+ count = root.Results.Count();
+ doctypes.AddRange(root.Results);
+ }
+ }
+ page++;
+ }
+ }
+
+ return doctypes;
+ }
+
+ public static async Task getExpirationDatesCalDAVAsync(bool onlyCurrentDocuments = true)
+ {
+ string caldav = @"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CAMCASS//Expiring Documents Calendar//EN
+X-WR-CALNAME:Expiring Documents
+X-WR-CALDESC:Onboarded staff's document expiration dates
+X-PUBLISHED-TTL:PT12H";
+
+ if (!isInitialized) { throw new NotSupportedException("API base URL or token not initialized"); }
+
+ var documents = (await getMergedDocumentMetadataAsync(onlyCurrentDocuments, false)).OrderBy(x=>x.Correspondent).ToList();
+ documents.Where(d => d.CustomFields.Any(f => f.Name.StartsWith("Expiration", StringComparison.OrdinalIgnoreCase)))
+ .ToList()
+ .ForEach(doc =>
+ {
+ var calDAVEvent = getCalDAVEvent(doc, doc.CustomFields.First(f => f.Name.StartsWith("expir", StringComparison.OrdinalIgnoreCase)).DateValue);
+ caldav += @$"
+{calDAVEvent}";
+ });
+
+ caldav += @"
+END:VCALENDAR";
+ return caldav;
+ }
+
+ private static string getCalDAVEvent(DocumentParsed document, DateTime date)
+ {
+ var nextDay = date.AddDays(1);
+ var DTSTAMP = $"{DateTime.UtcNow.Year:0000}{DateTime.UtcNow.Month:00}{DateTime.UtcNow.Day:00}T{DateTime.UtcNow.Hour:00}{DateTime.UtcNow.Minute:00}00Z";
+ return $@"BEGIN:VEVENT
+UID:{Guid.NewGuid().ToString().Replace("-","")}
+DTSTAMP:{DTSTAMP}
+DTSTART;VALUE=DATE:{date.Year:0000}{date.Month:00}{date.Day:00}
+DTEND;VALUE=DATE:{nextDay.Year:0000}{nextDay.Month:00}{nextDay.Day:00}
+SUMMARY:{document.DocumentType} - {document.Correspondent}
+END:VEVENT";
+ }
+
+ public static async Task> getMergedDocumentMetadataAsync(bool onlyCurrentDocuments = true, bool includeNonExpirableDocuments=true)
+ {
+ if (!isInitialized) { throw new NotSupportedException("API base URL or token not initialized"); }
+
+ var docs = onlyCurrentDocuments ? await getCurrentDocumentsAsync(includeNonExpirableDocuments) : await getAllDocumentsAsync();
+ var tags = await getTagsAsync();
+ var correspondents = await getCorrespondentsAsync();
+ var customFields = await getCustomFieldsAsync();
+ var documenttypes = await getDocumentTypesAsync();
+
+ DocumentParsed._tags = tags.ToArray();
+ DocumentParsed._correspondents = correspondents.ToArray();
+ DocumentParsed._customFields = customFields.ToArray();
+ DocumentParsed._documentTypes = documenttypes.ToArray();
+
+
+ var documents = new List();
+ docs.ToList().ForEach(document => documents.Add(DocumentParsed.FromDocument(document)));
+
+ return documents;
+ }
+ }
+}
\ No newline at end of file
diff --git a/paperless-ngx-export/paperless-ngx-export.csproj b/paperless-ngx-export/paperless-ngx-export.csproj
new file mode 100644
index 0000000..79cfdbc
--- /dev/null
+++ b/paperless-ngx-export/paperless-ngx-export.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net10.0
+ paperless_ngx_export
+ enable
+ enable
+
+
+
+
+
+
+
+
diff --git a/paperless-ngx-export/spreadsheet.cs b/paperless-ngx-export/spreadsheet.cs
new file mode 100644
index 0000000..312143d
--- /dev/null
+++ b/paperless-ngx-export/spreadsheet.cs
@@ -0,0 +1,212 @@
+using ClosedXML.Excel;
+using DocumentFormat.OpenXml.Drawing.Charts;
+using DocumentFormat.OpenXml.Spreadsheet;
+using paperless_ngx_export.Models;
+using RBush;
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace paperless_ngx_export
+{
+ public static class spreadsheet
+ {
+ public static async Task GetSummarySpreadsheetAsByteArrayAsync()
+ {
+ byte[] bytes = null;
+
+ using (var stream = new MemoryStream())
+ {
+ var workbook = await GetSummaryAsXLWorkbookAsync();
+ workbook.SaveAs(stream);
+ bytes = stream.ToArray();
+ stream.Close();
+ }
+
+ return bytes ?? new byte[0];
+ }
+
+ public static void PopulateDocumentTypeWorksheets(
+ IEnumerable documentTypes,
+ IEnumerable tags,
+ IEnumerable customFields,
+ IEnumerable docs,
+ XLWorkbook workbook,
+ bool onlyCurrentDocuments = true)
+ {
+ var headings = new string[]
+ {
+ "Correspondent",
+ "Role",
+ "Specialty",
+ "Document Type",
+ "Expiration Date",
+ "Tags"
+ };
+
+ var documents = docs.Where(x =>
+ {
+ if (!x.CustomFields.Any(xx => xx.Name.Contains("expir", StringComparison.OrdinalIgnoreCase)))
+ return true;
+
+ return onlyCurrentDocuments ? !x.Tags.Any(xx => xx.name.Contains("renewed", StringComparison.OrdinalIgnoreCase)) : true;
+ }).ToList();
+
+ foreach (var documentType in documentTypes.OrderBy(x=>!documents.Any(d=>d.DocumentType == x.name && d.CustomFields.Any(cf=>cf.Name.Contains("expir", StringComparison.OrdinalIgnoreCase)))).
+ ThenBy(x=>x.name))
+ {
+ var sheetName = documentType.name.Replace('/', '-');
+ var sheet = workbook.Worksheets.FirstOrDefault(x => x.Name == sheetName);
+ if (sheet == null)
+ {
+ sheet = workbook.Worksheets.Add(sheetName);
+ sheet.TabColor = XLColor.Green;
+
+ for (int i = 0; i < headings.Length; i++)
+ {
+ sheet.Cell(1, i + 1).Value = headings[i];
+ }
+
+ sheet.SheetView.Freeze(1, 2);
+ sheet.SetAutoFilter(true);
+ sheet.Cells("A1:ZZ1").Style.Font.Bold = true;
+ }
+
+ foreach (var document in documents.Where(x => x.DocumentType == documentType.name).OrderBy(x => x.Correspondent))
+ {
+ var rowColour = XLColor.Transparent;
+ var expirationDate = document.CustomFields.FirstOrDefault(x => x.Name.Contains("expir", StringComparison.OrdinalIgnoreCase))?.DateValue;
+
+ if (expirationDate != null && expirationDate <= DateTime.Today)
+ {
+ rowColour = XLColor.Red;
+ }
+ else if (expirationDate != null && expirationDate?.AddDays(-30) < DateTime.Today)
+ {
+ rowColour = XLColor.Orange;
+ }
+
+ if(sheet.TabColor == XLColor.Green
+ && rowColour != XLColor.Transparent)
+ {
+ sheet.TabColor = sheet.TabColor == XLColor.Red ? XLColor.Red : rowColour;
+ }
+
+ var rowIndex = sheet.LastRowUsed()?.RowNumber() + 1 ?? 2;
+ sheet.Cell(rowIndex, 1).Value = document.Correspondent;
+ sheet.Cell(rowIndex, 2).Value = document.CustomFields.FirstOrDefault(x=>x.Name == "Role")?.StringValue;
+ sheet.Cell(rowIndex, 3).Value = document.CustomFields.FirstOrDefault(x => x.Name == "Specialty")?.StringValue;
+ sheet.Cell(rowIndex, 4).Value = document.DocumentType;
+ sheet.Cell(rowIndex, 5).Value = expirationDate == null ? Blank.Value : expirationDate;
+ sheet.Cell(rowIndex, 5).Style.Fill.BackgroundColor = rowColour == XLColor.Transparent ? sheet.Cell(rowIndex, 5).Style.Fill.BackgroundColor : rowColour;
+ sheet.Cell(rowIndex, 6).Value = String.Concat(document.Tags.Select(x=>x.name + ' ').ToArray()).Trim();
+ }
+
+ sheet.Columns().AdjustToContents();
+ }
+ }
+
+ public static void PopulateSummaryWorksheet(
+ IEnumerable documentTypes,
+ IEnumerable tags,
+ IEnumerable customFields,
+ IEnumerable docs,
+ XLWorkbook workbook,
+ bool onlyCurrentDocuments = true)
+ {
+ var headers = new string[]
+ {
+ "Correspondent",
+ "Role (Specialty)",
+ "Professional Registration",
+ "Liability Insurance",
+ "ACLS",
+ "BLS",
+ "PALS",
+ "Vaccination Record",
+ "CV / Resumé",
+ "Misc Certificate",
+ "Credentialing / Privileges",
+ "Reference Letter"
+ };
+
+ var documents = docs.Where(x =>
+ {
+ if (!x.CustomFields.Any(xx => xx.Name.Contains("expir", StringComparison.OrdinalIgnoreCase)))
+ return true;
+
+ return onlyCurrentDocuments ? !x.Tags.Any(xx => xx.name.Contains("renewed", StringComparison.OrdinalIgnoreCase)) : true;
+ }).ToList();
+
+ var summarySheet = workbook.Worksheets.Add("Summary");
+ summarySheet.SheetView.Freeze(1, 2); //Freeze header row and, correspondent and role columns
+ for (int i = 1; i <= headers.Length; i++)
+ {
+ summarySheet.Cell(1, i).Value = headers[i - 1];
+ }
+ summarySheet.Cells("A1:ZZ1").Style.Font.Bold = true;
+ summarySheet.SetAutoFilter();
+
+ var row = 2;
+ foreach (var correspondent in documents.Select(x => x.Correspondent).Distinct())
+ {
+ summarySheet.Cell(row, 1).Value = correspondent;
+
+ foreach (var doc in documents.Where(x => x.Correspondent == correspondent))
+ {
+ if (summarySheet.Cell(row, 2).Value.Type == XLDataType.Blank)
+ {
+ var role = doc.CustomFields.FirstOrDefault(x => x.Name == "Role")?.Value?.ToString() ?? "";
+ var specialty = doc.CustomFields.FirstOrDefault(x => x.Name == "Specialty")?.Value?.ToString() ?? "";
+ role = $"{role} {(!string.IsNullOrWhiteSpace(specialty) ? $"({specialty})" : "")}".Trim();
+ summarySheet.Cell(row, 2).Value = role;
+ }
+
+ if (headers.IndexOf(doc.DocumentType.ToString()) + 1 is int column && column > 0)
+ {
+ XLCellValue cellValue = summarySheet.Cell(row, column).Value;
+
+ if (!cellValue.IsBlank)
+ {
+ continue;
+ }
+
+ if (doc.DocumentType == "Reference Letter")
+ {
+ cellValue = documents.Where(x => x.Correspondent == correspondent && x.DocumentType == "Reference Letter").Count();
+ ;
+ }
+ else if (doc.CustomFields.FirstOrDefault(x => x.Name == "Expiration Date") is CustomFieldParsed customFieldParsed &&
+ customFieldParsed.DateValue > DateTime.MinValue && customFieldParsed.DateValue < DateTime.MaxValue)
+ {
+ cellValue = XLCellValue.FromObject(customFieldParsed.DateValue);
+ }
+ else if (!doc.CustomFields.Any(x => x.Name == "Expiration Date"))
+ {
+ cellValue = "Present";
+ }
+
+ summarySheet.Cell(row, column).Value = cellValue;
+ }
+ }
+
+ row++;
+ }
+
+ summarySheet.Columns().AdjustToContents();
+ }
+
+ public static async Task GetSummaryAsXLWorkbookAsync()
+ {
+ var documenttypes = await api.getDocumentTypesAsync();
+ var tags = await api.getTagsAsync();
+ var customFields = await api.getCustomFieldsAsync();
+ var documents = (await api.getMergedDocumentMetadataAsync(false, true)).Where(x => !x.CustomFields.Any(xx => xx.Name == "Action:Renewed")).OrderBy(x => x.Correspondent).ToList();
+ var workbook = new XLWorkbook();
+
+ PopulateSummaryWorksheet(documenttypes, tags, customFields, documents, workbook, true);
+ PopulateDocumentTypeWorksheets(documenttypes, tags, customFields, documents, workbook, true);
+ return workbook;
+ }
+ }
+}