From f662d576d16085a30a5f067561fa053d2dd524de Mon Sep 17 00:00:00 2001 From: campbrian Date: Sat, 21 Feb 2026 10:30:14 -0700 Subject: [PATCH] Add project files. --- .dockerignore | 30 ++ .env | 2 + Dockerfile | 32 ++ Paperness-ngx-export.slnx | 5 + docker-build-image.sh | 1 + docker-compose.yml | 9 + paperless-ngx-export.service/Dockerfile | 28 ++ paperless-ngx-export.service/Program.cs | 80 +++++ .../Properties/launchSettings.json | 24 ++ .../appsettings.Development.json | 8 + paperless-ngx-export.service/appsettings.json | 9 + .../docker-compose.yml | 9 + .../paperless-ngx-export.service.csproj | 19 ++ .../TerminalProgram.cs | 21 ++ .../paperless-ngx-export.terminal.csproj | 15 + paperless-ngx-export/Models/Correspondent.cs | 18 + paperless-ngx-export/Models/CustomField.cs | 43 +++ .../Models/CustomFieldKeyValuePair.cs | 17 + .../Models/CustomFieldParsed.cs | 66 ++++ paperless-ngx-export/Models/Document.cs | 29 ++ paperless-ngx-export/Models/DocumentParsed.cs | 65 ++++ paperless-ngx-export/Models/DocumentType.cs | 18 + paperless-ngx-export/Models/ResultBase.cs | 11 + paperless-ngx-export/Models/RootObject.cs | 12 + paperless-ngx-export/Models/Tag.cs | 19 ++ paperless-ngx-export/api.cs | 310 ++++++++++++++++++ .../paperless-ngx-export.csproj | 15 + paperless-ngx-export/spreadsheet.cs | 212 ++++++++++++ 28 files changed, 1127 insertions(+) create mode 100644 .dockerignore create mode 100644 .env create mode 100644 Dockerfile create mode 100644 Paperness-ngx-export.slnx create mode 100644 docker-build-image.sh create mode 100644 docker-compose.yml create mode 100644 paperless-ngx-export.service/Dockerfile create mode 100644 paperless-ngx-export.service/Program.cs create mode 100644 paperless-ngx-export.service/Properties/launchSettings.json create mode 100644 paperless-ngx-export.service/appsettings.Development.json create mode 100644 paperless-ngx-export.service/appsettings.json create mode 100644 paperless-ngx-export.service/docker-compose.yml create mode 100644 paperless-ngx-export.service/paperless-ngx-export.service.csproj create mode 100644 paperless-ngx-export.terminal/TerminalProgram.cs create mode 100644 paperless-ngx-export.terminal/paperless-ngx-export.terminal.csproj create mode 100644 paperless-ngx-export/Models/Correspondent.cs create mode 100644 paperless-ngx-export/Models/CustomField.cs create mode 100644 paperless-ngx-export/Models/CustomFieldKeyValuePair.cs create mode 100644 paperless-ngx-export/Models/CustomFieldParsed.cs create mode 100644 paperless-ngx-export/Models/Document.cs create mode 100644 paperless-ngx-export/Models/DocumentParsed.cs create mode 100644 paperless-ngx-export/Models/DocumentType.cs create mode 100644 paperless-ngx-export/Models/ResultBase.cs create mode 100644 paperless-ngx-export/Models/RootObject.cs create mode 100644 paperless-ngx-export/Models/Tag.cs create mode 100644 paperless-ngx-export/api.cs create mode 100644 paperless-ngx-export/paperless-ngx-export.csproj create mode 100644 paperless-ngx-export/spreadsheet.cs 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; + } + } +}