**update** initial commit

This commit is contained in:
Aytac Kirmizi 2023-02-01 16:02:30 +01:00
commit 0a1e04d30d
89 changed files with 2978 additions and 0 deletions

27
.dockerignore Normal file
View File

@ -0,0 +1,27 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
**/appsettings.json
**/appsettings.*.json
LICENSE
README.md

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/
**/.env
.env

View File

@ -0,0 +1,13 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/contentModel.xml
/.idea.Linkding.Sync.iml
/modules.xml
/projectSettingsUpdater.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ActiveTabHighlighterConfiguration">
<option name="background">
<PersistentColor>
<option name="enabled" value="true" />
<option name="red" value="173" />
<option name="green" value="46" />
<option name="blue" value="156" />
</PersistentColor>
</option>
</component>
</project>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="accountSettings">
<option name="activeRegion" value="us-east-1" />
<option name="recentlyUsedRegions">
<list>
<option value="us-east-1" />
</list>
</option>
</component>
</project>

View File

@ -0,0 +1,414 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DBNavigator.Project.DataEditorManager">
<record-view-column-sorting-type value="BY_INDEX" />
<value-preview-text-wrapping value="true" />
<value-preview-pinned value="false" />
</component>
<component name="DBNavigator.Project.DatabaseEditorStateManager">
<last-used-providers />
</component>
<component name="DBNavigator.Project.DatabaseFileManager">
<open-files />
</component>
<component name="DBNavigator.Project.Settings">
<connections />
<browser-settings>
<general>
<display-mode value="TABBED" />
<navigation-history-size value="100" />
<show-object-details value="false" />
</general>
<filters>
<object-type-filter>
<object-type name="SCHEMA" enabled="true" />
<object-type name="USER" enabled="true" />
<object-type name="ROLE" enabled="true" />
<object-type name="PRIVILEGE" enabled="true" />
<object-type name="CHARSET" enabled="true" />
<object-type name="TABLE" enabled="true" />
<object-type name="VIEW" enabled="true" />
<object-type name="MATERIALIZED_VIEW" enabled="true" />
<object-type name="NESTED_TABLE" enabled="true" />
<object-type name="COLUMN" enabled="true" />
<object-type name="INDEX" enabled="true" />
<object-type name="CONSTRAINT" enabled="true" />
<object-type name="DATASET_TRIGGER" enabled="true" />
<object-type name="DATABASE_TRIGGER" enabled="true" />
<object-type name="SYNONYM" enabled="true" />
<object-type name="SEQUENCE" enabled="true" />
<object-type name="PROCEDURE" enabled="true" />
<object-type name="FUNCTION" enabled="true" />
<object-type name="PACKAGE" enabled="true" />
<object-type name="TYPE" enabled="true" />
<object-type name="TYPE_ATTRIBUTE" enabled="true" />
<object-type name="ARGUMENT" enabled="true" />
<object-type name="DIMENSION" enabled="true" />
<object-type name="CLUSTER" enabled="true" />
<object-type name="DBLINK" enabled="true" />
</object-type-filter>
</filters>
<sorting>
<object-type name="COLUMN" sorting-type="NAME" />
<object-type name="FUNCTION" sorting-type="NAME" />
<object-type name="PROCEDURE" sorting-type="NAME" />
<object-type name="ARGUMENT" sorting-type="POSITION" />
<object-type name="TYPE ATTRIBUTE" sorting-type="POSITION" />
</sorting>
<default-editors>
<object-type name="VIEW" editor-type="SELECTION" />
<object-type name="PACKAGE" editor-type="SELECTION" />
<object-type name="TYPE" editor-type="SELECTION" />
</default-editors>
</browser-settings>
<navigation-settings>
<lookup-filters>
<lookup-objects>
<object-type name="SCHEMA" enabled="true" />
<object-type name="USER" enabled="false" />
<object-type name="ROLE" enabled="false" />
<object-type name="PRIVILEGE" enabled="false" />
<object-type name="CHARSET" enabled="false" />
<object-type name="TABLE" enabled="true" />
<object-type name="VIEW" enabled="true" />
<object-type name="MATERIALIZED VIEW" enabled="true" />
<object-type name="INDEX" enabled="true" />
<object-type name="CONSTRAINT" enabled="true" />
<object-type name="DATASET TRIGGER" enabled="true" />
<object-type name="DATABASE TRIGGER" enabled="true" />
<object-type name="SYNONYM" enabled="false" />
<object-type name="SEQUENCE" enabled="true" />
<object-type name="PROCEDURE" enabled="true" />
<object-type name="FUNCTION" enabled="true" />
<object-type name="PACKAGE" enabled="true" />
<object-type name="TYPE" enabled="true" />
<object-type name="DIMENSION" enabled="false" />
<object-type name="CLUSTER" enabled="false" />
<object-type name="DBLINK" enabled="true" />
</lookup-objects>
<force-database-load value="false" />
<prompt-connection-selection value="true" />
<prompt-schema-selection value="true" />
</lookup-filters>
</navigation-settings>
<dataset-grid-settings>
<general>
<enable-zooming value="true" />
<enable-column-tooltip value="true" />
</general>
<sorting>
<nulls-first value="true" />
<max-sorting-columns value="4" />
</sorting>
<audit-columns>
<column-names value="" />
<visible value="true" />
<editable value="false" />
</audit-columns>
</dataset-grid-settings>
<dataset-editor-settings>
<text-editor-popup>
<active value="false" />
<active-if-empty value="false" />
<data-length-threshold value="100" />
<popup-delay value="1000" />
</text-editor-popup>
<values-actions-popup>
<show-popup-button value="true" />
<element-count-threshold value="1000" />
<data-length-threshold value="250" />
</values-actions-popup>
<general>
<fetch-block-size value="100" />
<fetch-timeout value="30" />
<trim-whitespaces value="true" />
<convert-empty-strings-to-null value="true" />
<select-content-on-cell-edit value="true" />
<large-value-preview-active value="true" />
</general>
<filters>
<prompt-filter-dialog value="true" />
<default-filter-type value="BASIC" />
</filters>
<qualified-text-editor text-length-threshold="300">
<content-types>
<content-type name="Text" enabled="true" />
<content-type name="Properties" enabled="true" />
<content-type name="XML" enabled="true" />
<content-type name="DTD" enabled="true" />
<content-type name="HTML" enabled="true" />
<content-type name="XHTML" enabled="true" />
<content-type name="CSS" enabled="true" />
<content-type name="SQL" enabled="true" />
<content-type name="PL/SQL" enabled="true" />
<content-type name="JavaScript" enabled="true" />
<content-type name="JSON" enabled="true" />
<content-type name="JSON5" enabled="true" />
<content-type name="YAML" enabled="true" />
<content-type name="C#" enabled="true" />
<content-type name="C++" enabled="true" />
</content-types>
</qualified-text-editor>
<record-navigation>
<navigation-target value="VIEWER" />
</record-navigation>
</dataset-editor-settings>
<code-editor-settings>
<general>
<show-object-navigation-gutter value="false" />
<show-spec-declaration-navigation-gutter value="true" />
<enable-spellchecking value="true" />
<enable-reference-spellchecking value="false" />
</general>
<confirmations>
<save-changes value="false" />
<revert-changes value="true" />
</confirmations>
</code-editor-settings>
<code-completion-settings>
<filters>
<basic-filter>
<filter-element type="RESERVED_WORD" id="keyword" selected="true" />
<filter-element type="RESERVED_WORD" id="function" selected="true" />
<filter-element type="RESERVED_WORD" id="parameter" selected="true" />
<filter-element type="RESERVED_WORD" id="datatype" selected="true" />
<filter-element type="RESERVED_WORD" id="exception" selected="true" />
<filter-element type="OBJECT" id="schema" selected="true" />
<filter-element type="OBJECT" id="role" selected="true" />
<filter-element type="OBJECT" id="user" selected="true" />
<filter-element type="OBJECT" id="privilege" selected="true" />
<user-schema>
<filter-element type="OBJECT" id="table" selected="true" />
<filter-element type="OBJECT" id="view" selected="true" />
<filter-element type="OBJECT" id="materialized view" selected="true" />
<filter-element type="OBJECT" id="index" selected="true" />
<filter-element type="OBJECT" id="constraint" selected="true" />
<filter-element type="OBJECT" id="trigger" selected="true" />
<filter-element type="OBJECT" id="synonym" selected="false" />
<filter-element type="OBJECT" id="sequence" selected="true" />
<filter-element type="OBJECT" id="procedure" selected="true" />
<filter-element type="OBJECT" id="function" selected="true" />
<filter-element type="OBJECT" id="package" selected="true" />
<filter-element type="OBJECT" id="type" selected="true" />
<filter-element type="OBJECT" id="dimension" selected="true" />
<filter-element type="OBJECT" id="cluster" selected="true" />
<filter-element type="OBJECT" id="dblink" selected="true" />
</user-schema>
<public-schema>
<filter-element type="OBJECT" id="table" selected="false" />
<filter-element type="OBJECT" id="view" selected="false" />
<filter-element type="OBJECT" id="materialized view" selected="false" />
<filter-element type="OBJECT" id="index" selected="false" />
<filter-element type="OBJECT" id="constraint" selected="false" />
<filter-element type="OBJECT" id="trigger" selected="false" />
<filter-element type="OBJECT" id="synonym" selected="false" />
<filter-element type="OBJECT" id="sequence" selected="false" />
<filter-element type="OBJECT" id="procedure" selected="false" />
<filter-element type="OBJECT" id="function" selected="false" />
<filter-element type="OBJECT" id="package" selected="false" />
<filter-element type="OBJECT" id="type" selected="false" />
<filter-element type="OBJECT" id="dimension" selected="false" />
<filter-element type="OBJECT" id="cluster" selected="false" />
<filter-element type="OBJECT" id="dblink" selected="false" />
</public-schema>
<any-schema>
<filter-element type="OBJECT" id="table" selected="true" />
<filter-element type="OBJECT" id="view" selected="true" />
<filter-element type="OBJECT" id="materialized view" selected="true" />
<filter-element type="OBJECT" id="index" selected="true" />
<filter-element type="OBJECT" id="constraint" selected="true" />
<filter-element type="OBJECT" id="trigger" selected="true" />
<filter-element type="OBJECT" id="synonym" selected="true" />
<filter-element type="OBJECT" id="sequence" selected="true" />
<filter-element type="OBJECT" id="procedure" selected="true" />
<filter-element type="OBJECT" id="function" selected="true" />
<filter-element type="OBJECT" id="package" selected="true" />
<filter-element type="OBJECT" id="type" selected="true" />
<filter-element type="OBJECT" id="dimension" selected="true" />
<filter-element type="OBJECT" id="cluster" selected="true" />
<filter-element type="OBJECT" id="dblink" selected="true" />
</any-schema>
</basic-filter>
<extended-filter>
<filter-element type="RESERVED_WORD" id="keyword" selected="true" />
<filter-element type="RESERVED_WORD" id="function" selected="true" />
<filter-element type="RESERVED_WORD" id="parameter" selected="true" />
<filter-element type="RESERVED_WORD" id="datatype" selected="true" />
<filter-element type="RESERVED_WORD" id="exception" selected="true" />
<filter-element type="OBJECT" id="schema" selected="true" />
<filter-element type="OBJECT" id="user" selected="true" />
<filter-element type="OBJECT" id="role" selected="true" />
<filter-element type="OBJECT" id="privilege" selected="true" />
<user-schema>
<filter-element type="OBJECT" id="table" selected="true" />
<filter-element type="OBJECT" id="view" selected="true" />
<filter-element type="OBJECT" id="materialized view" selected="true" />
<filter-element type="OBJECT" id="index" selected="true" />
<filter-element type="OBJECT" id="constraint" selected="true" />
<filter-element type="OBJECT" id="trigger" selected="true" />
<filter-element type="OBJECT" id="synonym" selected="true" />
<filter-element type="OBJECT" id="sequence" selected="true" />
<filter-element type="OBJECT" id="procedure" selected="true" />
<filter-element type="OBJECT" id="function" selected="true" />
<filter-element type="OBJECT" id="package" selected="true" />
<filter-element type="OBJECT" id="type" selected="true" />
<filter-element type="OBJECT" id="dimension" selected="true" />
<filter-element type="OBJECT" id="cluster" selected="true" />
<filter-element type="OBJECT" id="dblink" selected="true" />
</user-schema>
<public-schema>
<filter-element type="OBJECT" id="table" selected="true" />
<filter-element type="OBJECT" id="view" selected="true" />
<filter-element type="OBJECT" id="materialized view" selected="true" />
<filter-element type="OBJECT" id="index" selected="true" />
<filter-element type="OBJECT" id="constraint" selected="true" />
<filter-element type="OBJECT" id="trigger" selected="true" />
<filter-element type="OBJECT" id="synonym" selected="true" />
<filter-element type="OBJECT" id="sequence" selected="true" />
<filter-element type="OBJECT" id="procedure" selected="true" />
<filter-element type="OBJECT" id="function" selected="true" />
<filter-element type="OBJECT" id="package" selected="true" />
<filter-element type="OBJECT" id="type" selected="true" />
<filter-element type="OBJECT" id="dimension" selected="true" />
<filter-element type="OBJECT" id="cluster" selected="true" />
<filter-element type="OBJECT" id="dblink" selected="true" />
</public-schema>
<any-schema>
<filter-element type="OBJECT" id="table" selected="true" />
<filter-element type="OBJECT" id="view" selected="true" />
<filter-element type="OBJECT" id="materialized view" selected="true" />
<filter-element type="OBJECT" id="index" selected="true" />
<filter-element type="OBJECT" id="constraint" selected="true" />
<filter-element type="OBJECT" id="trigger" selected="true" />
<filter-element type="OBJECT" id="synonym" selected="true" />
<filter-element type="OBJECT" id="sequence" selected="true" />
<filter-element type="OBJECT" id="procedure" selected="true" />
<filter-element type="OBJECT" id="function" selected="true" />
<filter-element type="OBJECT" id="package" selected="true" />
<filter-element type="OBJECT" id="type" selected="true" />
<filter-element type="OBJECT" id="dimension" selected="true" />
<filter-element type="OBJECT" id="cluster" selected="true" />
<filter-element type="OBJECT" id="dblink" selected="true" />
</any-schema>
</extended-filter>
</filters>
<sorting enabled="true">
<sorting-element type="RESERVED_WORD" id="keyword" />
<sorting-element type="RESERVED_WORD" id="datatype" />
<sorting-element type="OBJECT" id="column" />
<sorting-element type="OBJECT" id="table" />
<sorting-element type="OBJECT" id="view" />
<sorting-element type="OBJECT" id="materialized view" />
<sorting-element type="OBJECT" id="index" />
<sorting-element type="OBJECT" id="constraint" />
<sorting-element type="OBJECT" id="trigger" />
<sorting-element type="OBJECT" id="synonym" />
<sorting-element type="OBJECT" id="sequence" />
<sorting-element type="OBJECT" id="procedure" />
<sorting-element type="OBJECT" id="function" />
<sorting-element type="OBJECT" id="package" />
<sorting-element type="OBJECT" id="type" />
<sorting-element type="OBJECT" id="dimension" />
<sorting-element type="OBJECT" id="cluster" />
<sorting-element type="OBJECT" id="dblink" />
<sorting-element type="OBJECT" id="schema" />
<sorting-element type="OBJECT" id="role" />
<sorting-element type="OBJECT" id="user" />
<sorting-element type="RESERVED_WORD" id="function" />
<sorting-element type="RESERVED_WORD" id="parameter" />
</sorting>
<format>
<enforce-code-style-case value="true" />
</format>
</code-completion-settings>
<execution-engine-settings>
<statement-execution>
<fetch-block-size value="100" />
<execution-timeout value="20" />
<debug-execution-timeout value="600" />
<focus-result value="false" />
<prompt-execution value="false" />
</statement-execution>
<script-execution>
<command-line-interfaces />
<execution-timeout value="300" />
</script-execution>
<method-execution>
<execution-timeout value="30" />
<debug-execution-timeout value="600" />
<parameter-history-size value="10" />
</method-execution>
</execution-engine-settings>
<operation-settings>
<transactions>
<uncommitted-changes>
<on-project-close value="ASK" />
<on-disconnect value="ASK" />
<on-autocommit-toggle value="ASK" />
</uncommitted-changes>
<multiple-uncommitted-changes>
<on-commit value="ASK" />
<on-rollback value="ASK" />
</multiple-uncommitted-changes>
</transactions>
<session-browser>
<disconnect-session value="ASK" />
<kill-session value="ASK" />
<reload-on-filter-change value="false" />
</session-browser>
<compiler>
<compile-type value="KEEP" />
<compile-dependencies value="ASK" />
<always-show-controls value="false" />
</compiler>
<debugger>
<debugger-type value="JDBC" />
<use-generic-runners value="true" />
</debugger>
</operation-settings>
<ddl-file-settings>
<extensions>
<mapping file-type-id="VIEW" extensions="vw" />
<mapping file-type-id="TRIGGER" extensions="trg" />
<mapping file-type-id="PROCEDURE" extensions="prc" />
<mapping file-type-id="FUNCTION" extensions="fnc" />
<mapping file-type-id="PACKAGE" extensions="pkg" />
<mapping file-type-id="PACKAGE_SPEC" extensions="pks" />
<mapping file-type-id="PACKAGE_BODY" extensions="pkb" />
<mapping file-type-id="TYPE" extensions="tpe" />
<mapping file-type-id="TYPE_SPEC" extensions="tps" />
<mapping file-type-id="TYPE_BODY" extensions="tpb" />
</extensions>
<general>
<lookup-ddl-files value="true" />
<create-ddl-files value="false" />
<synchronize-ddl-files value="true" />
<use-qualified-names value="false" />
<make-scripts-rerunnable value="true" />
</general>
</ddl-file-settings>
<general-settings>
<regional-settings>
<date-format value="MEDIUM" />
<number-format value="UNGROUPED" />
<locale value="SYSTEM_DEFAULT" />
<use-custom-formats value="false" />
</regional-settings>
<environment>
<environment-types>
<environment-type id="development" name="Development" description="Development environment" color="-2430209/-12296320" readonly-code="false" readonly-data="false" />
<environment-type id="integration" name="Integration" description="Integration environment" color="-2621494/-12163514" readonly-code="true" readonly-data="false" />
<environment-type id="production" name="Production" description="Productive environment" color="-11574/-10271420" readonly-code="true" readonly-data="true" />
<environment-type id="other" name="Other" description="" color="-1576/-10724543" readonly-code="false" readonly-data="false" />
</environment-types>
<visibility-settings>
<connection-tabs value="true" />
<dialog-headers value="true" />
<object-editor-tabs value="true" />
<script-editor-tabs value="false" />
<execution-result-tabs value="true" />
</visibility-settings>
</environment>
</general-settings>
</component>
</project>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GitToolBoxProjectSettings">
<option name="commitMessageIssueKeyValidationOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
<option name="commitMessageValidationEnabledOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
</component>
</project>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders>
<Path>examples</Path>
</attachedFolders>
<explicitIncludes />
<explicitExcludes />
</component>
</project>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

19
Dockerfile_Linkding Normal file
View File

@ -0,0 +1,19 @@
FROM mcr.microsoft.com/dotnet/runtime:6.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["src/Linkding/Linkding.csproj", "src/Linkding/"]
RUN dotnet restore "src/Linkding/Linkding.csproj"
COPY . .
WORKDIR "/src/src/Linkding"
RUN dotnet build "Linkding.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "Linkding.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
RUN mkdir ./data
ENTRYPOINT ["dotnet", "Linkding.dll"]

19
Dockerfile_Wallabag Normal file
View File

@ -0,0 +1,19 @@
FROM mcr.microsoft.com/dotnet/runtime:6.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["src/Wallabag/Wallabag.csproj", "src/Wallabag/"]
RUN dotnet restore "src/Wallabag/Wallabag.csproj"
COPY . .
WORKDIR "/src/src/Wallabag"
RUN dotnet build "Wallabag.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "Wallabag.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
RUN mkdir ./data
ENTRYPOINT ["dotnet", "Wallabag.dll"]

61
Linkding.Sync.sln Normal file
View File

@ -0,0 +1,61 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{3167CB7E-6412-49DB-BB42-E91E07B51C5E}"
ProjectSection(SolutionItems) = preProject
README.md = README.md
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Services", "Services", "{2775FBD9-7954-4A8B-8831-31C7670209A3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Linkding.Client", "src\Services\Linkding.Client\Linkding.Client.csproj", "{1880BB32-4013-45F5-9DB4-6864F9836525}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wallabag.Client", "src\Services\Wallabag.Client\Wallabag.Client.csproj", "{2776E05F-F35A-4421-A792-0F9B0CC48475}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Domain", "Domain", "{586A8F51-1A5D-42B9-8A37-8B2010905EB1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "src\Domain\Core\Core.csproj", "{0EA1E116-6560-4C1D-8EC8-6ACE3F5E1C25}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastructure", "{953FCC4E-C759-47DD-B20A-78FBA2E34F35}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wallabag", "src\Wallabag\Wallabag.csproj", "{5EFB26BC-CD34-4DE6-B0E3-9F1E387D4177}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Linkding", "src\Linkding\Linkding.csproj", "{3E78F171-D237-46DF-8A27-DAADE7CA1940}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{1880BB32-4013-45F5-9DB4-6864F9836525}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1880BB32-4013-45F5-9DB4-6864F9836525}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1880BB32-4013-45F5-9DB4-6864F9836525}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1880BB32-4013-45F5-9DB4-6864F9836525}.Release|Any CPU.Build.0 = Release|Any CPU
{2776E05F-F35A-4421-A792-0F9B0CC48475}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2776E05F-F35A-4421-A792-0F9B0CC48475}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2776E05F-F35A-4421-A792-0F9B0CC48475}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2776E05F-F35A-4421-A792-0F9B0CC48475}.Release|Any CPU.Build.0 = Release|Any CPU
{0EA1E116-6560-4C1D-8EC8-6ACE3F5E1C25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0EA1E116-6560-4C1D-8EC8-6ACE3F5E1C25}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0EA1E116-6560-4C1D-8EC8-6ACE3F5E1C25}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0EA1E116-6560-4C1D-8EC8-6ACE3F5E1C25}.Release|Any CPU.Build.0 = Release|Any CPU
{5EFB26BC-CD34-4DE6-B0E3-9F1E387D4177}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5EFB26BC-CD34-4DE6-B0E3-9F1E387D4177}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5EFB26BC-CD34-4DE6-B0E3-9F1E387D4177}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5EFB26BC-CD34-4DE6-B0E3-9F1E387D4177}.Release|Any CPU.Build.0 = Release|Any CPU
{3E78F171-D237-46DF-8A27-DAADE7CA1940}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3E78F171-D237-46DF-8A27-DAADE7CA1940}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3E78F171-D237-46DF-8A27-DAADE7CA1940}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3E78F171-D237-46DF-8A27-DAADE7CA1940}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{2775FBD9-7954-4A8B-8831-31C7670209A3} = {3167CB7E-6412-49DB-BB42-E91E07B51C5E}
{1880BB32-4013-45F5-9DB4-6864F9836525} = {2775FBD9-7954-4A8B-8831-31C7670209A3}
{2776E05F-F35A-4421-A792-0F9B0CC48475} = {2775FBD9-7954-4A8B-8831-31C7670209A3}
{586A8F51-1A5D-42B9-8A37-8B2010905EB1} = {3167CB7E-6412-49DB-BB42-E91E07B51C5E}
{0EA1E116-6560-4C1D-8EC8-6ACE3F5E1C25} = {586A8F51-1A5D-42B9-8A37-8B2010905EB1}
{953FCC4E-C759-47DD-B20A-78FBA2E34F35} = {3167CB7E-6412-49DB-BB42-E91E07B51C5E}
{5EFB26BC-CD34-4DE6-B0E3-9F1E387D4177} = {3167CB7E-6412-49DB-BB42-E91E07B51C5E}
{3E78F171-D237-46DF-8A27-DAADE7CA1940} = {3167CB7E-6412-49DB-BB42-E91E07B51C5E}
EndGlobalSection
EndGlobal

126
README.md Normal file
View File

@ -0,0 +1,126 @@
# Linkding Sync
LinkdingSync is a collection of tools that make life with [Linkding](https://github.com/sissbruecker/linkding) easier.
One of the workers is for syncing to [Wallabag](https://wallabag.org/en).
## Getting Started
It is recommended to use the Docker images. Otherwise, a .NET 6 environment is required to customize and build the code.
## Environment Variables
For the containers to work, the environment variables must be passed. This can be done either directly via the Docker run **-e** switch, via the **environment** settings in a Docker compose definition, or via an environment variable file.
### WallabagSync
Environment variables for the wallabag worker.
| Variable | Value | Description | Attention |
|------------------------|-----------|--------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Worker__Intervall | int (>=0) | This value sets the execution schedule in minutes. 1 = every minute, 10 = every 10 minutes (default value 0) | 0 = runs only one time. The container will be stopped after the execution. This method is the preferred way to run the container with a scheduler (e.g. cron) |
| Worker__SyncTag | text | The linkding tag to create the bookmarks in Wallabag. (default value 'readlater') | |
| Linkding__Url | text | URL to the linkding instance | |
| Linkding__Key | text | The linkding application key | [Instructions](https://github.com/sissbruecker/linkding/blob/master/docs/API.md) |
| Wallabag__Url | text | URL to the Wallabag instance | |
| Wallabag__Username | text | Wallabag User Name | |
| Wallabag__Password | text | Wallabag User Password | |
| Wallabag__ClientId | text | Wallabag Client Id | |
| Wallabag__ClientSecret | text | Wallabag Client Secret | |
### LinkdingUpdater
Environment variables for the linkding worker.
| Variable | Value | Description | Attention |
|------------------------|-----------|--------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Worker__Intervall | int (>=0) | This value sets the execution schedule in minutes. 1 = every minute, 10 = every 10 minutes | 0 = runs only one time. The container will be stopped after the execution. This method is the preferred way to run the container with a scheduler (e.g. cron) |
| Worker__SyncTag | text | The linkding tag to create the bookmarks in Wallabag. | |
| Linkding__Url | text | URL to the linkding instance | |
| Linkding__Key | text | The linkding application key | [Instructions](https://github.com/sissbruecker/linkding/blob/master/docs/API.md) |
## Configuration
The following explains the configuration options.
### WallabagSync
The configuration is optional. In the configuration (**YAML File**) rules can be defined in regex to exclude certain domains from sync.
Exampel:
````yaml
excludedDomains:
- name: youtube
pattern: 'https://[[a-zA-Z0-9]+\.]?(youtube)\.com(?:/.*)?'
- name: ebay
pattern: 'https://[[a-zA-Z0-9]+\.]?(ebay)\.(com|de|fr)(?:/.*)?'
- name: amazon
pattern: 'https://[[a-zA-Z0-9]+\.]?(amazon)\.(com|de|fr)(?:/.*)?'
````
With this configuration every matching bookmark from linkding will be excluded from the sync.
### LinkdingUpdater
The configuration is optional. In the configuration (**YAML File**) rules can be defined in regex to assign tags dynamically. Additionally tags can be defined to domains.
If operated without a configuration file, only the year of the tag is added (currently).
Example:
````yaml
urlTagMapping:
- name: microsoft_azure
url: https://github.com/azure
- name: microsoft_azuread
url: https://github.com/AzureAD
- name: microsoft_dotnet
url: https://github.com/dotnet-architecture
taggingRule:
- name: reddit
pattern: https://(?:www\.)?(reddit)\.com(?:/r/)?([ a-zA-Z0-9\-\+_ ]+)?(?:/.*)?
replace: $1,$2
- name: microsoft
pattern: https://([ a-zA-Z0-9 ]+)?[ \. ]?(microsoft)\.com(?:/.*)?
replace: $1,$2
- name: microsoft_docs
pattern: 'https://(?:docs)\.(?:microsoft)\.com[ / ]?(?: [ a-zA-Z0-9\-\+_ ]+)(?:/)?([ a-zA-Z0-9\-\+_ ]+)?(?:/)?([ a-zA-Z0-9\-\+_ ]+)?(?:/.*)?'
replace: $1,$2
- name: youtube
pattern: https://[ [ a-zA-Z0-9 ]+\. ]?(youtube)\.com(?:/.*)?
replace: $1
- name: ebay
pattern: https://[ [ a-zA-Z0-9 ]+\. ]?(ebay)\.(com|de|fr)(?:/.*)?
replace: $1
- name: amazon
pattern: https://[ [ a-zA-Z0-9 ]+\. ]?(amazon)\.(com|de|fr)(?:/.*)?
replace: $1
- name: docker
pattern: https://([ a-zA-Z0-9 ]+)?[ \. ]?(docker)\.com(?:/.*)?
replace: $1,$2
- name: xbox
pattern: https://[ [ a-zA-Z0-9 ]+\. ]?(xbox)\.com(?:/.*)?
replace: $1
- name: github
pattern: https://([ a-zA-Z0-9 ]+)?[ \. ]?(github)\.com[ / ]?([ a-zA-Z0-9\-\+_ ]+)(?:/)?([ a-zA-Z0-9\-\+_ ]+)?(?:/.*)?
replace: $1,$2,$3,$4
- name: github.io
pattern: https://([ a-zA-Z0-9 ]+)\.(github)\.io[ / ]?([ a-zA-Z0-9\-\+_ ]+)(?:/)?([ a-zA-Z0-9\-\+_ ]+)?(?:/.*)?
replace: $1,$2,$3
````
## Docker Run
```
docker run --rm -it --env-file .env -v <path>/config.yml:/app/data/config.yml linkdingsync/wallabag:latest
```
You can also
## Docker Compose
You can find [examples](./examples/) in the examples folder..
- [Wallabag Example](./examples/wallabag/)
- [LinkdingUpdater Example](./examples/linkding/)
## Build Docker Image
### LinkdingUpdater
```
docker build -t linkdingsync/linkdingupdater:latest -f .\Dockerfile_Linkding .
```
### WallabagSync
```
docker build -t linkdingsync/wallabag:latest -f .\Dockerfile_Wallabag .
```

View File

@ -0,0 +1,39 @@
urlTagMapping:
- name: microsoft_azure
url: https://github.com/azure
- name: microsoft_azuread
url: https://github.com/AzureAD
- name: microsoft_dotnet
url: https://github.com/dotnet-architecture
taggingRule:
- name: reddit
pattern: https://(?:www\.)?(reddit)\.com(?:/r/)?([ a-zA-Z0-9\-\+_ ]+)?(?:/.*)?
replace: $1,$2
- name: microsoft
pattern: https://([ a-zA-Z0-9 ]+)?[ \. ]?(microsoft)\.com(?:/.*)?
replace: $1,$2
- name: microsoft_docs
pattern: 'https://(?:docs)\.(?:microsoft)\.com[ / ]?(?: [ a-zA-Z0-9\-\+_ ]+)(?:/)?([ a-zA-Z0-9\-\+_ ]+)?(?:/)?([ a-zA-Z0-9\-\+_ ]+)?(?:/.*)?'
replace: $1,$2
- name: youtube
pattern: https://[ [ a-zA-Z0-9 ]+\. ]?(youtube)\.com(?:/.*)?
replace: $1
- name: ebay
pattern: https://[ [ a-zA-Z0-9 ]+\. ]?(ebay)\.(com|de|fr)(?:/.*)?
replace: $1
- name: amazon
pattern: https://[ [ a-zA-Z0-9 ]+\. ]?(amazon)\.(com|de|fr)(?:/.*)?
replace: $1
- name: docker
pattern: https://([ a-zA-Z0-9 ]+)?[ \. ]?(docker)\.com(?:/.*)?
replace: $1,$2
- name: xbox
pattern: https://[ [ a-zA-Z0-9 ]+\. ]?(xbox)\.com(?:/.*)?
replace: $1
- name: github
pattern: https://([ a-zA-Z0-9 ]+)?[ \. ]?(github)\.com[ / ]?([ a-zA-Z0-9\-\+_ ]+)(?:/)?([ a-zA-Z0-9\-\+_ ]+)?(?:/.*)?
replace: $1,$2,$3,$4
- name: github.io
pattern: https://([ a-zA-Z0-9 ]+)\.(github)\.io[ / ]?([ a-zA-Z0-9\-\+_ ]+)(?:/)?([ a-zA-Z0-9\-\+_ ]+)?(?:/.*)?
replace: $1,$2,$3

View File

@ -0,0 +1,13 @@
version: '3.9'
services:
linkdingupdater:
image: linkdingsync/linkdingupdater:latest
volumes:
- ./config.yml:/data/config.yml
# env_file:
# - .env
environment:
- Worker__Intervall=0
- Linkding__Url=https://<url>
- Linkding__Key=<secret>

View File

@ -0,0 +1,7 @@
excludedDomains:
- name: youtube
pattern: 'https://[[a-zA-Z0-9]+\.]?(youtube)\.com(?:/.*)?'
- name: ebay
pattern: 'https://[[a-zA-Z0-9]+\.]?(ebay)\.(com|de|fr)(?:/.*)?'
- name: amazon
pattern: 'https://[[a-zA-Z0-9]+\.]?(amazon)\.(com|de|fr)(?:/.*)?'

View File

@ -0,0 +1,19 @@
version: '3.9'
services:
wallabagsync:
image: linkdingsync/wallabag:latest
volumes:
- ./config.yml:/data/config.yml
# env_file:
# - .env
environment:
- Worker__Intervall=0
- Worker__SyncTag=<tagName>
- Linkding__Url=https://<url>
- Linkding__Key=<secret>
- Wallabag__Url=https://<url>
- Wallabag__Username=<username>
- Wallabag__Password=<password>
- Wallabag__ClientId=<clientId>
- Wallabag__ClientSecret=<secret>

7
global.json Normal file
View File

@ -0,0 +1,7 @@
{
"sdk": {
"version": "6.0.0",
"rollForward": "latestMinor",
"allowPrerelease": false
}
}

View File

@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Core.Entities.Linkding;
namespace Linkding.Client
{
public interface ILinkdingService
{
Task<IEnumerable<Bookmark>> GetBookmarksAsync(int limit = 100, int offset = 0);
Task<IEnumerable<Bookmark>> GetAllBookmarksAsync();
Task UpdateBookmarkCollectionAsync(IEnumerable<Bookmark> bookmarks);
Task UpdateBookmarkCollectionAsync(IEnumerable<BookmarkUpdatePayload> bookmarks);
Task UpdateBookmarkAsync(BookmarkUpdatePayload bookmark);
Task<BookmarksResult> GetBookmarkResultsAsync(int limit = 100, int offset = 0);
Task<BookmarksResult> GetBookmarkResultsAsync(string url);
}
}

View File

@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Core.Entities.Wallabag;
namespace Core.Abstraction
{
public interface IWallabagService
{
Task<AuthenticationHeaderValue> GetAuthenticationHeaderAsync(IEnumerable<string> scopes = null);
Task<HttpResponseMessage> GetAsync(string endpoint, IEnumerable<string> scopes = null,
bool httpCompletionResponseContentRead = false);
Task<T> GetJsonAsync<T>(string endpoint, IEnumerable<string> scopes = null);
Task<HttpResponseMessage> PostWithFormDataAsnyc(string endpoint, FormUrlEncodedContent content,
IEnumerable<string> scopes = null);
Task<HttpResponseMessage> PostAsync(string endpoint, HttpContent content,
IEnumerable<string> scopes = null);
Task<HttpResponseMessage> PostAsync(string endpoint, HttpRequestMessage request,
IEnumerable<string> scopes = null);
Task<HttpResponseMessage> PutAsync(string endpoint, HttpContent content,
IEnumerable<string> scopes = null);
Task<HttpResponseMessage> DeleteAsync(string endpoint, IEnumerable<string> scopes = null,
HttpContent content = null);
Task<IEnumerable<WallabagItem>> GetEntries(string format = "json", int limit = 50, bool full = false);
Task<WallabagItem> GetEntryById(int id, string format = "json");
Task<WallabagItem> AddEntryByUrl(string url, IEnumerable<string> tags = null, string format = "json");
}
}

View File

@ -0,0 +1,30 @@
using System;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Wallabag.Client.Converters
{
public class DateTimeConverterForCustomStandard : JsonConverter<DateTime>
{
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var dateTimeString = reader.GetString();
if (string.IsNullOrEmpty(dateTimeString))
{
return DateTime.MinValue;
}
DateTime dt = DateTime.ParseExact(dateTimeString, "yyyy-MM-dd'T'HH:mm:ssK",
CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal);
return dt;
}
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString());
}
}
}

View File

@ -0,0 +1,20 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Wallabag.Client.Converters
{
public class DateTimeOffsetConverterUsingDateTimeParse : JsonConverter<DateTimeOffset>
{
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var dateTimeString = reader.GetString();
return DateTimeOffset.Parse(dateTimeString);
}
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString());
}
}
}

View File

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,39 @@
using System;
using System.Text.Json.Serialization;
namespace Core.Entities.Linkding
{
public class Bookmark : BookmarkBase
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("website_title")]
public string WebsiteTitle { get; set; }
[JsonPropertyName("website_description")]
public string WebsiteDescription { get; set; }
[JsonPropertyName("is_archived")]
public bool IsArchived { get; set; }
[JsonPropertyName("unread")]
public bool Unread { get; set; }
[JsonPropertyName("date_added")]
public DateTime DateAdded { get; set; }
[JsonPropertyName("date_modified")]
public DateTime DateModified { get; set; }
}
}
// "id": 1,
// "url": "https://example.com",
// "title": "Example title",
// "description": "Example description",
// "website_title": "Website title",
// "website_description": "Website description",
// "is_archived": false,
// "unread": false,
// "tag_names": [
// "tag1",
// "tag2"
// ],
// "date_added": "2020-09-26T09:46:23.006313Z",
// "date_modified": "2020-09-26T16:01:14.275335Z"

View File

@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Core.Entities.Linkding
{
public class BookmarkBase
{
[JsonPropertyName("url")]
public string Url { get; set; }
[JsonPropertyName("title")]
public string Title { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("tag_names")]
public IEnumerable<string> TagNames { get; set; } = new List<string>();
}
}

View File

@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Core.Entities.Linkding
{
public class BookmarkCreatePayload : BookmarkBase
{
[JsonPropertyName("is_archived")]
public bool IsArchived { get; set; } = false;
[JsonPropertyName("unread")] public bool Unread { get; set; } = false;
}
}

View File

@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace Core.Entities.Linkding
{
public class BookmarkUpdatePayload : BookmarkBase
{
[JsonPropertyName("id")]
public int Id { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace Core.Entities.Linkding
{
public class BookmarkUpdateResult : BookmarkUpdatePayload
{
public bool Success { get; set; } = true;
}
}

View File

@ -0,0 +1,12 @@
using System.Collections.Generic;
namespace Core.Entities.Linkding
{
public class BookmarksResult
{
public long Count { get; set; }
public string? Next { get; set; }
public string? Previous { get; set; }
public List<Bookmark?> Results { get; set; }
}
}

View File

@ -0,0 +1,20 @@
using Core.Entities.Linkding;
namespace LinkdingUpdater.Handler
{
public class HandlerResult
{
public bool PerformAction { get; set; } = false;
public LinkdingItemAction Action { get; set; }
public bool HasError { get; set; } = false;
public string ErrorMessage { get; set; } = string.Empty;
public Bookmark Instance { get; set; }
}
}
public enum LinkdingItemAction
{
Update,
Delete
}

View File

@ -0,0 +1,15 @@
using System;
using System.Text.Json.Serialization;
namespace Core.Entities.Linkding
{
public class Tag
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("date_added")]
public DateTime DateAdded { get; set; }
}
}

View File

@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace Core.Entities.Linkding
{
public class TagCreatePayload
{
[JsonPropertyName("name")]
public string Name { get; set; }
}
}

View File

@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Wallabag.Client.Converters;
namespace Core.Entities.Wallabag
{
public class Annotation
{
[JsonPropertyName("user")] public string User { get; set; }
[JsonPropertyName("annotator_schema_version")]
public string AnnotatorSchemaVersion { get; set; }
[JsonPropertyName("id")] public int Id { get; set; }
[JsonPropertyName("text")] public string Text { get; set; }
[JsonConverter(typeof(DateTimeConverterForCustomStandard))]
[JsonPropertyName("created_at")] public DateTime? CreatedAt { get; set; } = null;
[JsonConverter(typeof(DateTimeConverterForCustomStandard))]
[JsonPropertyName("updated_at")] public DateTime? UpdatedAt { get; set; } = null;
[JsonPropertyName("quote")] public string Quote { get; set; }
[JsonPropertyName("ranges")] public List<Range> Ranges { get; set; }
}
}

View File

@ -0,0 +1,11 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Core.Entities.Wallabag
{
public class Embedded
{
[JsonPropertyName("items")]
public List<WallabagItem> Items { get; set; }
}
}

View File

@ -0,0 +1,28 @@
using System.Text.Json.Serialization;
namespace Core.Entities.Wallabag
{
public class Headers
{
[JsonPropertyName("server")] public string Server { get; set; }
[JsonPropertyName("date")] public string Date { get; set; }
[JsonPropertyName("content-type")] public string ContentType { get; set; }
[JsonPropertyName("content-length")] public string ContentLength { get; set; }
[JsonPropertyName("connection")] public string Connection { get; set; }
[JsonPropertyName("x-powered-by")] public string XPoweredBy { get; set; }
[JsonPropertyName("cache-control")] public string CacheControl { get; set; }
[JsonPropertyName("etag")] public string Etag { get; set; }
[JsonPropertyName("vary")] public string Vary { get; set; }
[JsonPropertyName("strict-transport-security")]
public string StrictTransportSecurity { get; set; }
}
}

View File

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace Core.Entities.Wallabag
{
public class HttpLink
{
[JsonPropertyName("href")] public string Href { get; set; }
}
}

View File

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace Core.Entities.Wallabag
{
public class Links
{
[JsonPropertyName("self")] public HttpLink? Self { get; set; } = null;
}
}

View File

@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace Core.Entities.Wallabag
{
public class QueryLinks : Links
{
[JsonPropertyName("first")] public HttpLink? First { get; set; } = null;
[JsonPropertyName("last")] public HttpLink? Last { get; set; } = null;
[JsonPropertyName("next")] public HttpLink? Next { get; set; } = null;
}
}

View File

@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace Core.Entities.Wallabag
{
public class Range
{
[JsonPropertyName("start")] public string Start { get; set; }
[JsonPropertyName("startOffset")] public string StartOffset { get; set; }
[JsonPropertyName("end")] public string End { get; set; }
[JsonPropertyName("endOffset")] public string EndOffset { get; set; }
}
}

View File

@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace Core.Entities.Wallabag
{
public class Tag
{
[JsonPropertyName("id")] public int Id { get; set; }
[JsonPropertyName("label")] public string Label { get; set; }
[JsonPropertyName("slug")] public string Slug { get; set; }
}
}

View File

@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Wallabag.Client.Converters;
namespace Core.Entities.Wallabag
{
public class WallabagItem
{
[JsonPropertyName("is_archived")] public int IsArchived { get; set; }
[JsonPropertyName("is_starred")] public int IsStarred { get; set; }
[JsonPropertyName("user_name")] public string UserName { get; set; }
[JsonPropertyName("user_email")] public string UserEmail { get; set; }
[JsonPropertyName("user_id")] public int UserId { get; set; }
[JsonPropertyName("tags")] public List<Tag> Tags { get; set; }
[JsonPropertyName("is_public")] public bool IsPublic { get; set; }
[JsonPropertyName("id")] public int Id { get; set; }
[JsonPropertyName("uid")] public string? Uid { get; set; } = null;
[JsonPropertyName("title")] public string Title { get; set; }
[JsonPropertyName("url")] public string Url { get; set; }
[JsonPropertyName("hashed_url")] public string HashedUrl { get; set; }
[JsonPropertyName("origin_url")] public string? OriginUrl { get; set; } = null;
[JsonPropertyName("given_url")] public string GivenUrl { get; set; }
[JsonPropertyName("hashed_given_url")] public string HashedGivenUrl { get; set; }
[JsonConverter(typeof(DateTimeConverterForCustomStandard))]
[JsonPropertyName("archived_at")] public DateTime? ArchivedAt { get; set; } = null;
[JsonPropertyName("content")] public string? Content { get; set; } = null;
[JsonConverter(typeof(DateTimeConverterForCustomStandard))]
[JsonPropertyName("created_at")] public DateTime CreatedAt { get; set; }
[JsonConverter(typeof(DateTimeConverterForCustomStandard))]
[JsonPropertyName("updated_at")] public DateTime UpdatedAt { get; set; }
[JsonConverter(typeof(DateTimeConverterForCustomStandard))]
[JsonPropertyName("published_at")] public DateTime PublishedAt { get; set; }
[JsonPropertyName("published_by")] public List<string> PublishedBy { get; set; }
[JsonConverter(typeof(DateTimeConverterForCustomStandard))]
[JsonPropertyName("starred_at")] public DateTime? StarredAt { get; set; } = null;
[JsonPropertyName("annotations")] public List<Annotation> Annotations { get; set; }
[JsonPropertyName("mimetype")] public string Mimetype { get; set; }
[JsonPropertyName("language")] public string Language { get; set; }
[JsonPropertyName("reading_time")] public int ReadingTime { get; set; }
[JsonPropertyName("domain_name")] public string DomainName { get; set; }
[JsonPropertyName("preview_picture")] public string PreviewPicture { get; set; }
[JsonPropertyName("http_status")] public string HttpStatus { get; set; }
[JsonPropertyName("headers")] public Headers Headers { get; set; }
[JsonPropertyName("_links")] public Links Links { get; set; }
}
}

View File

@ -0,0 +1,25 @@
using System.Text.Json.Serialization;
namespace Core.Entities.Wallabag
{
public class WallabagQuery
{
[JsonPropertyName("page")]
public int Page { get; set; }
[JsonPropertyName("limit")]
public int Limit { get; set; }
[JsonPropertyName("pages")]
public int Pages { get; set; }
[JsonPropertyName("total")]
public int Total { get; set; }
[JsonPropertyName("_links")]
public QueryLinks QueryLinks { get; set; }
[JsonPropertyName("_embedded")]
public Embedded Embedded { get; set; }
}
}

View File

@ -0,0 +1,14 @@
using System.Threading.Tasks;
using Core.Entities.Linkding;
using LinkdingUpdater.Handler;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace Core.Handler
{
public interface ILinkdingTaskHandler
{
string Command { get; }
Task<HandlerResult> ProcessAsync(Bookmark bookmark, ILogger logger, IConfiguration configuration);
}
}

View File

@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Core.Entities.Wallabag;
using Linkding.Client;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace Core.Handler
{
public interface ISyncTaskHandler<T>
{
Type HandlerType { get; }
string Command { get; }
Task ProcessAsync(IEnumerable<WallabagItem> items, T destinationService, ILinkdingService linkdingService, ILogger logger, IConfiguration configuration);
}
}

19
src/Linkding/Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM mcr.microsoft.com/dotnet/runtime:6.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["src/LinkdingService/LinkdingService.csproj", "src/LinkdingService/"]
RUN dotnet restore "src/LinkdingService/LinkdingService.csproj"
COPY . .
WORKDIR "/src/src/LinkdingService"
RUN dotnet build "LinkdingService.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "LinkdingService.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
RUN mkdir ./data
ENTRYPOINT ["dotnet", "LinkdingService.dll"]

View File

@ -0,0 +1,18 @@
using Linkding.Options;
using Linkding.Settings;
// ReSharper disable once CheckNamespace
namespace Microsoft.Extensions.DependencyInjection;
public static class ServiceRegistrationExtensions
{
public static IServiceCollection Add_Linkding_Worker(this IServiceCollection services,
IConfiguration configuration)
{
var configSection = configuration.GetSection(WorkerSettings.Position);
services.Configure<WorkerSettings>(configSection);
services.AddSingleton<SettingsService>();
return services;
}
}

View File

@ -0,0 +1,71 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Core.Abstraction;
using Core.Entities.Linkding;
using Core.Handler;
using Linkding.Settings;
using LinkdingUpdater.Handler;
namespace Linkding.Handler;
public class AddPopularSitesAsTagHandler : ILinkdingTaskHandler
{
private record RegexExpressionGroups(string Expression, string Replace);
public string Command { get; } = "AddPopularSitesAsTag";
public async Task<HandlerResult> ProcessAsync(Bookmark bookmark, ILogger logger, IConfiguration configuration)
{
var settings = SettingsService.Settings;
var returnValue = new HandlerResult() {Instance = bookmark};
Regex r = null;
Match m = null;
foreach (var regexEntry in settings.taggingRule)
{
try
{
r = new Regex(regexEntry.pattern, RegexOptions.IgnoreCase);
m = r.Match(returnValue.Instance.Url);
if (m.Success)
{
var tagsCommaSeparated = r.Replace(returnValue.Instance.Url, regexEntry.replace);
if (!string.IsNullOrEmpty(tagsCommaSeparated))
{
var tags = tagsCommaSeparated.Split(',');
foreach (var tag in tags)
{
if (!string.IsNullOrEmpty(tag) && !returnValue.Instance.TagNames.Contains(tag) &&
returnValue.Instance.TagNames.FirstOrDefault(x => x.ToLower() == tag.ToLower()) == null)
{
returnValue.Instance.TagNames = returnValue.Instance.TagNames.Add(tag);
returnValue.PerformAction = true;
returnValue.Action = LinkdingItemAction.Update;
}
}
}
}
}
finally
{
r = null;
m = null;
}
}
foreach (var urlKeyValue in settings.urlTagMapping)
{
if (returnValue.Instance.Url.ToLower().StartsWith(urlKeyValue.url.ToLower()) && returnValue.Instance.TagNames.FirstOrDefault(x => x.ToLower() == urlKeyValue.name.ToLower()) == null)
{
returnValue.Instance.TagNames = returnValue.Instance.TagNames.Add(urlKeyValue.name);
returnValue.PerformAction = true;
}
}
return returnValue;
}
}

View File

@ -0,0 +1,51 @@
using Core.Abstraction;
using Core.Entities.Linkding;
using Core.Handler;
using LinkdingUpdater.Handler;
namespace Linkding.Handler;
public class AddYearToBookmarkHandler : ILinkdingTaskHandler
{
public string Command { get; } = "AddYearToBookmark";
public async Task<HandlerResult> ProcessAsync(Bookmark bookmark, ILogger logger, IConfiguration configuration)
{
var returnValue = new HandlerResult() {Instance = bookmark};
var update = false;
var createdYear = returnValue.Instance.DateAdded.GetYear();
if (createdYear != "1970")
{
var tagName = returnValue.Instance.TagNames.FirstOrDefault(x => x.Equals(createdYear));
if (tagName == null)
{
logger.LogInformation(
$"Detected bookmark ({returnValue.Instance.WebsiteTitle} - {returnValue.Instance.Id}) without year-tag ... Try to update");
returnValue.Instance.TagNames = returnValue.Instance.TagNames.Add(createdYear);
update = true;
}
}
else
{
var wrongTagName = returnValue.Instance.TagNames.FirstOrDefault(x => x.Equals("1970"));
if (wrongTagName != null)
{
logger.LogInformation(
$"Detected bookmark ({returnValue.Instance.WebsiteTitle} - {returnValue.Instance.Id}) with '1970' year-tag ... Try to update");
returnValue.Instance.TagNames = returnValue.Instance.TagNames.Where(x => !x.Equals("1970")).Select(x => x);
update = true;
}
}
if (update)
{
logger.LogInformation($"Start updating bookmark {returnValue.Instance.WebsiteTitle} - {returnValue.Instance.Id}");
returnValue.PerformAction = true;
returnValue.Action = LinkdingItemAction.Update;
}
return returnValue;
}
}

View File

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-LinkdingService-80165DE2-FA70-4803-B366-DF8F24CF86BE</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.1" />
<PackageReference Include="YamlDotNet" Version="12.3.1" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Services\Linkding.Client\Linkding.Client.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,8 @@
namespace Linkding.Options;
public class WorkerSettings
{
public const string Position = "Worker";
public int Intervall { get; set; } = 0;
}

11
src/Linkding/Program.cs Normal file
View File

@ -0,0 +1,11 @@
using Linkding;
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureServices((ctx, services) =>
{
services.Add_Linkding_HttpClient(ctx.Configuration);
services.AddHostedService<Worker>();
})
.Build();
await host.RunAsync();

View File

@ -0,0 +1,11 @@
{
"profiles": {
"LinkdingService": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,68 @@
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace Linkding.Settings;
public class SettingYaml
{
public List<UrlTagMapping> urlTagMapping { get; set; } = new ();
public List<TaggingRule> taggingRule { get; set; } = new ();
}
public class UrlTagMapping
{
public string name { get; set; }
public string url { get; set; }
}
public class TaggingRule
{
public string name { get; set; }
public string pattern { get; set; }
public string replace { get; set; }
}
public class SettingsService
{
private const string fileName = "data/config.yml";
private static SettingYaml _settings = null;
public static SettingYaml Settings
{
get
{
if (_settings == null)
{
Initialize();
}
return _settings;
}
private set
{
_settings = value;
}
}
private static void Initialize()
{
var filePath = Path.Combine(Environment.CurrentDirectory, fileName);
var fileInfo = new FileInfo(filePath);
if (fileInfo.Exists)
{
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance) // see height_in_inches in sample yml
.Build();
var yml = File.ReadAllText(fileInfo.FullName);
Settings = deserializer.Deserialize<SettingYaml>(yml);
}
else
{
Settings = new SettingYaml();
}
}
}

178
src/Linkding/Worker.cs Normal file
View File

@ -0,0 +1,178 @@
using Core.Abstraction;
using Core.Entities.Linkding;
using Core.Handler;
using Linkding.Client;
using Linkding.Client.Options;
using Linkding.Options;
using Microsoft.Extensions.Options;
namespace Linkding;
public class Worker : BackgroundService
{
private readonly IHostApplicationLifetime _hostApplicationLifetime;
private readonly ILogger<Worker> _logger;
private readonly LinkdingService _linkdingService;
private readonly LinkdingSettings _linkdingSettings;
private readonly WorkerSettings _settings;
private readonly IConfiguration _configuration;
public Worker(ILogger<Worker> logger, LinkdingService linkdingService,
IOptions<LinkdingSettings> linkdingSettings, IOptions<WorkerSettings> settings, IConfiguration configuration, IHostApplicationLifetime hostApplicationLifetime)
{
_logger = logger;
_linkdingService = linkdingService;
_configuration = configuration;
_hostApplicationLifetime = hostApplicationLifetime;
_settings = settings.Value;
_linkdingSettings = linkdingSettings.Value;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
await RunTaskHandler();
int delay = _settings.Intervall * 60000;
if (delay > 0)
{
_logger.LogInformation($"Worker paused for: {_settings.Intervall} minutes");
await Task.Delay(delay, stoppingToken);
}
else
{
_logger.LogInformation($"Intervall value is '0' --> stopping worker");
_hostApplicationLifetime.StopApplication();
}
}
}
public async Task RunTaskHandler()
{
if (!string.IsNullOrEmpty(_linkdingSettings.Url) && _linkdingSettings.UpdateBookmarks)
{
_logger.LogInformation($"Starting updating bookmarks for {_linkdingSettings.Url}");
_logger.LogInformation("Collecting Handler");
var handlers = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(s => s.GetTypes())
.Where(p => typeof(ILinkdingTaskHandler).IsAssignableFrom(p) && p.IsClass);
var updatedBookmarksCount = 0;
var updateBookmarks = new List<Bookmark>();
var deleteBookmarks = new List<Bookmark>();
if (handlers != null && handlers.Count() > 0)
{
var linkdingBookmarks = await _linkdingService.GetAllBookmarksAsync();
if (linkdingBookmarks.Count() > 0)
{
_logger.LogInformation($"{linkdingBookmarks.Count()} bookmarks found in {_linkdingSettings.Url}");
foreach (var handler in handlers)
{
ILinkdingTaskHandler handlerInstance = null;
try
{
handlerInstance = (ILinkdingTaskHandler) Activator.CreateInstance(handler);
foreach (var linkdingBookmark in linkdingBookmarks)
{
try
{
_logger.LogDebug($"Start executing {handlerInstance.Command}");
// var updateBookmark = updateBookmarks.FirstOrDefault(x => x.Id == linkdingBookmark.Id);
var existingBookmarkIndexInt =
updateBookmarks.FindIndex(x => x.Id == linkdingBookmark.Id);
var bookmarkInstance = existingBookmarkIndexInt != -1
? updateBookmarks[existingBookmarkIndexInt]
: linkdingBookmark;
var result = await handlerInstance.ProcessAsync(bookmarkInstance, _logger, _configuration);
if (result.HasError)
{
_logger.LogWarning(result.ErrorMessage, handlerInstance.Command);
}
else
{
if (result.PerformAction)
{
if (result.Action == LinkdingItemAction.Delete)
{
if (existingBookmarkIndexInt != -1)
{
updateBookmarks.RemoveAt(existingBookmarkIndexInt);
}
var bookmarkToDelete = deleteBookmarks.FirstOrDefault(x =>
x.Url.ToLower() == result.Instance.Url.ToLower());
if (bookmarkToDelete == null)
{
deleteBookmarks.Add(result.Instance);
}
}
else
{
if (existingBookmarkIndexInt != -1)
{
updateBookmarks[existingBookmarkIndexInt] = result.Instance;
}
else
{
updateBookmarks.Add(result.Instance);
}
}
}
}
_logger.LogDebug($"Finished {handlerInstance.Command}");
}
catch (Exception e)
{
Console.WriteLine(e);
var message = $"... {e.Message}";
if (handlerInstance != null && !string.IsNullOrEmpty(handlerInstance.Command))
{
message = $"Error while executing {handlerInstance.Command}! {message}";
}
else
{
message = $"Error while executing handler! {message}";
}
_logger.LogError(message, "Calling Handler", e);
// throw;
}
}
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
}
else
{
_logger.LogInformation($"no bookmarks found in {_linkdingSettings.Url}");
}
if (updateBookmarks.Count() > 0)
{
_logger.LogDebug($"Start updating bookmarks");
await _linkdingService.UpdateBookmarkCollectionAsync(updateBookmarks);
_logger.LogDebug($"Successfully updated bookmarks");
}
}
_logger.LogInformation($"Finished updating bookmarks for {_linkdingSettings.Url}");
}
}
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

39
src/Linkding/config.yml Normal file
View File

@ -0,0 +1,39 @@
urlTagMapping:
- name: microsoft_azure
url: https://github.com/azure
- name: microsoft_azuread
url: https://github.com/AzureAD
- name: microsoft_dotnet
url: https://github.com/dotnet-architecture
taggingRule:
- name: reddit
pattern: https://(?:www\.)?(reddit)\.com(?:/r/)?([ a-zA-Z0-9\-\+_ ]+)?(?:/.*)?
replace: $1,$2
- name: microsoft
pattern: https://([ a-zA-Z0-9 ]+)?[ \. ]?(microsoft)\.com(?:/.*)?
replace: $1,$2
- name: microsoft_docs
pattern: "https://(?:docs)\.(?:microsoft)\.com[ / ]?(?: [ a-zA-Z0-9\-\+_ ]+)(?:/)?([ a-zA-Z0-9\-\+_ ]+)?(?:/)?([ a-zA-Z0-9\-\+_ ]+)?(?:/.*)?"
replace: $1,$2
- name: youtube
pattern: https://[ [ a-zA-Z0-9 ]+\. ]?(youtube)\.com(?:/.*)?
replace: $1
- name: ebay
pattern: https://[ [ a-zA-Z0-9 ]+\. ]?(ebay)\.(com|de|fr)(?:/.*)?
replace: $1
- name: amazon
pattern: https://[ [ a-zA-Z0-9 ]+\. ]?(amazon)\.(com|de|fr)(?:/.*)?
replace: $1
- name: docker
pattern: https://([ a-zA-Z0-9 ]+)?[ \. ]?(docker)\.com(?:/.*)?
replace: $1,$2
- name: xbox
pattern: https://[ [ a-zA-Z0-9 ]+\. ]?(xbox)\.com(?:/.*)?
replace: $1
- name: github
pattern: https://([ a-zA-Z0-9 ]+)?[ \. ]?(github)\.com[ / ]?([ a-zA-Z0-9\-\+_ ]+)(?:/)?([ a-zA-Z0-9\-\+_ ]+)?(?:/.*)?
replace: $1,$2,$3,$4
- name: github.io
pattern: https://([ a-zA-Z0-9 ]+)\.(github)\.io[ / ]?([ a-zA-Z0-9\-\+_ ]+)(?:/)?([ a-zA-Z0-9\-\+_ ]+)?(?:/.*)?
replace: $1,$2,$3

View File

@ -0,0 +1,12 @@
using AutoMapper;
using Core.Entities.Linkding;
namespace Linkding.Client.Automapper;
public class LinkdingBookmarkProfile : Profile
{
public LinkdingBookmarkProfile()
{
CreateMap<Bookmark, BookmarkUpdatePayload>();
}
}

View File

@ -0,0 +1,35 @@
using System.Net.Http.Headers;
using Linkding.Client;
using Linkding.Client.Options;
using Microsoft.Extensions.Configuration;
using Polly;
using Polly.Extensions.Http;
// ReSharper disable once CheckNamespace
namespace Microsoft.Extensions.DependencyInjection;
public static class ServiceRegistrationExtensions
{
public static IServiceCollection Add_Linkding_HttpClient(this IServiceCollection services,
IConfiguration configuration)
{
var configSection = configuration.GetSection(LinkdingSettings.Position);
services.Configure<LinkdingSettings>(configSection);
services.AddHttpClient<LinkdingService>()
.SetHandlerLifetime(TimeSpan.FromMinutes(5)) //Set lifetime to five minutes
.AddPolicyHandler(GetRetryPolicy());
services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies());
return services;
}
static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
return HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.NotFound)
.WaitAndRetryAsync(6, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2,
retryAttempt)));
}
}

View File

@ -0,0 +1,38 @@
namespace System;
public static class System
{
public static DateTime CreateDateTime(this long ticks)
{
var dateTimeOffset = DateTimeOffset.FromUnixTimeSeconds(ticks);
return dateTimeOffset.UtcDateTime;
}
public static DateTime CreateDateTime(this string ticksString)
{
if (long.TryParse(ticksString, out var dateAddedUnixEpoch))
{
return dateAddedUnixEpoch.CreateDateTime();
}
return default;
}
public static string GetYear(this DateTime date)
{
return date.ToString("yyyy");
}
public static string GetYear(this long ticks)
{
var dateTime = ticks.CreateDateTime();
return dateTime.GetYear();
}
public static string GetYear(this string ticksString)
{
var dateTime = ticksString.CreateDateTime();
return dateTime.GetYear();
}
}

View File

@ -0,0 +1,12 @@
// ReSharper disable once CheckNamespace
namespace System.Collections.Generic;
public static class SystemCollectionGenericExtesions
{
public static IEnumerable<T> Add<T>(this IEnumerable<T> e, T value) {
foreach ( var cur in e) {
yield return cur;
}
yield return value;
}
}

View File

@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>AnyCPU</Platforms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="12.0.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="6.0.13" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<Compile Remove="Models\Bookmark.cs" />
<Compile Remove="Models\BookmarkBase.cs" />
<Compile Remove="Models\BookmarkCreatePayload.cs" />
<Compile Remove="Models\BookmarkUpdatePayload.cs" />
<Compile Remove="Models\BookmarkUpdateResult.cs" />
<Compile Remove="Models\Tag.cs" />
<Compile Remove="Models\TagCreatePayload.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Core\Core.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,127 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using AutoMapper;
using Core.Entities.Linkding;
using Linkding.Client.Options;
using Microsoft.Extensions.Options;
namespace Linkding.Client;
public class LinkdingService : ILinkdingService
{
private readonly LinkdingSettings _settings;
private readonly IMapper _mapper;
public readonly HttpClient _client;
public LinkdingService(HttpClient client, IOptions<LinkdingSettings> settings, IMapper mapper)
{
_settings = settings.Value;
_client = client;
_mapper = mapper;
_client.BaseAddress = new Uri(_settings.Url);
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", _settings.Key);
}
private LinkdingService(string url, string key)
{
_client = new HttpClient();
_client.BaseAddress = new Uri(url);
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", key);
}
public async Task<IEnumerable<Bookmark>> GetBookmarksAsync(int limit = 100, int offset = 0)
{
var bookmarks = new List<Bookmark>();
var result = await GetBookmarkResultsAsync(limit, offset);
if (result != null && result.Results?.Count() > 0)
{
bookmarks = result.Results;
}
return bookmarks;
}
public async Task<IEnumerable<Bookmark>> GetAllBookmarksAsync()
{
IEnumerable<Bookmark> bookmarks = new List<Bookmark>();
var result = await GetBookmarkResultsAsync();
if (result != null && result.Results?.Count() > 0)
{
bookmarks = result.Results;
if (result.Count > 100)
{
while (!string.IsNullOrEmpty(result.Next))
{
result = await GetBookmarkResultsAsync(result.Next);
if (result.Results?.Count() > 0)
{
bookmarks = bookmarks.Concat(result.Results);
}
else
{
break;
}
}
}
}
return bookmarks;
}
public async Task UpdateBookmarkCollectionAsync(IEnumerable<Bookmark> bookmarks)
{
foreach (var bookmark in bookmarks)
{
var payload = _mapper.Map<BookmarkUpdatePayload>(bookmark);
await UpdateBookmarkAsync(payload);
}
}
public async Task UpdateBookmarkCollectionAsync(IEnumerable<BookmarkUpdatePayload> bookmarks)
{
foreach (var bookmark in bookmarks)
{
await UpdateBookmarkAsync(bookmark);
}
}
public async Task UpdateBookmarkAsync(BookmarkUpdatePayload bookmark)
{
var result = await _client.PutAsJsonAsync($"/api/bookmarks/{bookmark.Id}/", bookmark);
if (result.IsSuccessStatusCode)
{
}
else
{
}
}
public async Task<BookmarksResult> GetBookmarkResultsAsync(int limit = 100, int offset = 0)
{
BookmarksResult bookmarkResult = null;
var url = $"/api/bookmarks/";
bookmarkResult = await GetBookmarkResultsAsync(url);
return bookmarkResult;
}
public async Task<BookmarksResult> GetBookmarkResultsAsync(string url)
{
BookmarksResult bookmarkResult = null;
bookmarkResult = await _client.GetFromJsonAsync<BookmarksResult>(url);
return bookmarkResult;
}
public static LinkdingService Create(string url, string key)
{
return new LinkdingService(url, key);
}
}

View File

@ -0,0 +1,10 @@
namespace Linkding.Client.Options;
public class LinkdingSettings
{
public const string Position = "Linkding";
public string Key { get; set; }
public string Url { get; set; }
public bool UpdateBookmarks { get; set; } = true;
}

View File

@ -0,0 +1,6 @@
namespace Wallabag.Client.Contracts;
public interface IAccessTokenProvider
{
Task<string> GetToken(IEnumerable<string> scopes);
}

View File

@ -0,0 +1,28 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Wallabag.Client.Converters;
public class DateTimeConverterForCustomStandard : JsonConverter<DateTime>
{
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var dateTimeString = reader.GetString();
if (string.IsNullOrEmpty(dateTimeString))
{
return DateTime.MinValue;
}
DateTime dt = DateTime.ParseExact(dateTimeString, "yyyy-MM-dd'T'HH:mm:ssK",
CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal);
return dt;
}
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString());
}
}

View File

@ -0,0 +1,18 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Wallabag.Client.Converters;
public class DateTimeOffsetConverterUsingDateTimeParse : JsonConverter<DateTimeOffset>
{
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var dateTimeString = reader.GetString();
return DateTimeOffset.Parse(dateTimeString);
}
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString());
}
}

View File

@ -0,0 +1,39 @@
using System.Net.Http.Headers;
using Microsoft.Extensions.Configuration;
using Polly;
using Polly.Extensions.Http;
using Wallabag.Client;
using Wallabag.Client.Contracts;
using Wallabag.Client.OAuth;
using Wallabag.Client.Options;
// ReSharper disable once CheckNamespace
namespace Microsoft.Extensions.DependencyInjection;
public static class ServiceRegistrationExtensions
{
public static IServiceCollection Add_Wallabag_HttpClient(this IServiceCollection services,
IConfiguration configuration)
{
var configSection = configuration.GetSection(WallabagSettings.Position);
services.Configure<WallabagSettings>(configSection);
services.AddScoped<IAccessTokenProvider, OAuthTokenProvider>();
services.AddScoped<AuthenticationClient>();
services.AddHttpClient<WallabagService>()
.SetHandlerLifetime(TimeSpan.FromMinutes(5)) //Set lifetime to five minutes
.AddPolicyHandler(GetRetryPolicy());
// services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies());
return services;
}
static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
return HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.NotFound)
.WaitAndRetryAsync(6, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2,
retryAttempt)));
}
}

View File

@ -0,0 +1,26 @@
// ReSharper disable once CheckNamespace
namespace System;
public static class URIExtensions
{
public static string AppendToURL(this string uri1, string uri2)
{
return AppendToUrlInternal(uri1, uri2);
}
public static string AppendToURL(this string baseURL, params string[] segments)
{
return AppendToUrlInternal(baseURL, segments);
}
private static string AppendToUrlInternal(this string baseURL, params string[] segments)
{
return string.Join("/", new[] { baseURL.TrimEnd('/') }
.Concat(segments.Select(s => s.Trim('/'))));
}
public static Uri Append(this Uri uri, params string[] paths)
{
return new Uri(paths.Aggregate(uri.AbsoluteUri, (current, path) => string.Format("{0}/{1}", current.TrimEnd('/'), path.TrimStart('/'))));
}
}

View File

@ -0,0 +1,6 @@
namespace Wallabag.Client.Models;
public class WallabagEntry : WallabagPayload
{
public int Id { get; set; }
}

View File

@ -0,0 +1,17 @@
namespace Wallabag.Client.Models;
public class WallabagPayload
{
public string Url { get; set; }
public string Title { get; set; }
public string Tags { get; set; }
public int Archive { get; set; }
public int Starred { get; set; }
public string Content { get; set; }
public string Language { get; set; }
public string PreviewPicture { get; set; }
public DateTime PublishedAt { get; set; }
public string Authors { get; set; }
public int Publich { get; set; }
public string OriginUrl { get; set; }
}

View File

@ -0,0 +1,77 @@
using System.Net.Http.Json;
using Microsoft.Extensions.Options;
using Wallabag.Client.Options;
namespace Wallabag.Client.OAuth;
public record WallabagToken(string token_type, string access_token, string refresh_token, int expires_in, string scope);
public class AuthenticationClient
{
protected const string AuthPath = "/oauth/v2/token";
public readonly HttpClient _client;
public readonly WallabagSettings _settings;
public AuthenticationClient(HttpClient client, IOptions<WallabagSettings> settings)
{
_client = client;
_settings = settings.Value;
// var baseAdress = new Uri(_settings.Url);
// client.BaseAddress = baseAdress.Append(AuthPath);
client.BaseAddress = new Uri(_settings.Url);
}
public async Task<WallabagToken> Authenticate(IEnumerable<string> scopes = null)
{
var parameters = new List<KeyValuePair<string, string>>
{
new("client_id", _settings.ClientId),
new("client_secret", _settings.ClientSecret),
new("grant_type", _settings.GrandType),
new("username", _settings.Username),
new("password", _settings.Password)
};
if (scopes != null && scopes.Count() > 0)
{
parameters.Add(new("scope", string.Join(" ", scopes)));
}
var response = await _client.PostAsync("oauth/v2/token", new FormUrlEncodedContent(parameters));
response.EnsureSuccessStatusCode();
var authentication = await response.Content.ReadFromJsonAsync<WallabagToken>();
if (authentication == null)
{
throw new HttpRequestException("Could not retrieve authentication data.");
}
return authentication;
}
public async Task<WallabagToken> RefreshToken(string refreshToken, IEnumerable<string> scopes = null)
{
var parameters = new List<KeyValuePair<string, string>>
{
new("client_id", _settings.ClientId),
new("client_secret", _settings.ClientSecret),
new("grant_type", "refresh_token"),
new("refresh_token", refreshToken),
new("username", _settings.Username),
new("password", _settings.Password)
};
if (scopes != null && scopes.Count() > 0)
{
parameters.Add(new("scope", string.Join(" ", scopes)));
}
var response = await _client.PostAsync("oauth/v2/token", new FormUrlEncodedContent(parameters));
response.EnsureSuccessStatusCode();
var authentication = await response.Content.ReadFromJsonAsync<WallabagToken>();
if (authentication == null)
{
throw new HttpRequestException("Could not retrieve authentication data.");
}
return authentication;
}
}

View File

@ -0,0 +1,43 @@
using Wallabag.Client.Contracts;
namespace Wallabag.Client.OAuth;
public class OAuthTokenProvider : IAccessTokenProvider
{
protected record TokenCache(string access_token, DateTime expires);
private readonly AuthenticationClient _client;
protected Dictionary<string, TokenCache> cache = new ();
public OAuthTokenProvider(AuthenticationClient client)
{
_client = client;
}
public async Task<string> GetToken(IEnumerable<string> scopes = null)
{
string cacheKey = "wallabag+token";
if (scopes != null && scopes.Count() > 0)
{
cacheKey = string.Join('+', scopes);
}
if (cache.ContainsKey(cacheKey))
{
var tokenCache = cache[cacheKey];
if (tokenCache.expires > DateTime.Now)
{
return tokenCache.access_token;
}
else
{
cache.Remove(cacheKey);
}
}
var auth = await _client.Authenticate(scopes);
cache.Add(cacheKey, new TokenCache(auth.access_token, DateTime.Now.AddSeconds(auth.expires_in)));
return auth.access_token;
}
}

View File

@ -0,0 +1,13 @@
namespace Wallabag.Client.Options;
public class WallabagSettings
{
public const string Position = "Wallabag";
public string Url { get; set; } = "https://app.wallabag.it";
public string Username { get; set; }
public string Password { get; set; }
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string GrandType { get; set; } = "password";
}

View File

@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="6.0.13" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<Compile Remove="Models\Result\Annotation.cs" />
<Compile Remove="Models\Result\Embedded.cs" />
<Compile Remove="Models\Result\Headers.cs" />
<Compile Remove="Models\Result\HttpLink.cs" />
<Compile Remove="Models\Result\Links.cs" />
<Compile Remove="Models\Result\QueryLinks.cs" />
<Compile Remove="Models\Result\Range.cs" />
<Compile Remove="Models\Result\Tag.cs" />
<Compile Remove="Models\Result\WallabagItem.cs" />
<Compile Remove="Models\Result\WallabagQuery.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Core\Core.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,108 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Core.Abstraction;
using Microsoft.Extensions.Options;
using Wallabag.Client.Contracts;
using Wallabag.Client.Converters;
using Wallabag.Client.Options;
namespace Wallabag.Client;
public partial class WallabagService : IWallabagService
{
private readonly WallabagSettings _settings;
private IAccessTokenProvider _accessTokenProvider;
public readonly HttpClient _client;
public WallabagService(HttpClient client, IOptions<WallabagSettings> settings,
IAccessTokenProvider accessTokenProvider)
{
_client = client;
_accessTokenProvider = accessTokenProvider;
_settings = settings.Value;
_client.BaseAddress = new Uri(_settings.Url);
}
public async Task<AuthenticationHeaderValue> GetAuthenticationHeaderAsync(IEnumerable<string> scopes = null)
{
return new AuthenticationHeaderValue("Bearer", await _accessTokenProvider.GetToken(scopes));
}
public async Task<HttpResponseMessage> GetAsync(string endpoint, IEnumerable<string> scopes = null,
bool httpCompletionResponseContentRead = false)
{
var request = new HttpRequestMessage(HttpMethod.Get, endpoint);
request.Headers.Authorization = await GetAuthenticationHeaderAsync(scopes);
HttpResponseMessage response = null;
if (httpCompletionResponseContentRead)
{
response = await _client.SendAsync(request, HttpCompletionOption.ResponseContentRead);
}
else
{
response = await _client.SendAsync(request);
}
response.EnsureSuccessStatusCode();
return response;
}
public async Task<T> GetJsonAsync<T>(string endpoint, IEnumerable<string> scopes = null)
{
var response = await GetAsync(endpoint, scopes);
return await response.Content.ReadFromJsonAsync<T>();
}
public async Task<HttpResponseMessage> PostWithFormDataAsnyc(string endpoint, FormUrlEncodedContent content,
IEnumerable<string> scopes = null)
{
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint);
request.Content = content;
return await PostAsync(endpoint, request, scopes);
}
public async Task<HttpResponseMessage> PostAsync(string endpoint, HttpContent content,
IEnumerable<string> scopes = null)
{
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint);
request.Content = content;
return await PostAsync(endpoint, request, scopes);
}
public async Task<HttpResponseMessage> PostAsync(string endpoint, HttpRequestMessage request,
IEnumerable<string> scopes = null)
{
request.Headers.Authorization = await GetAuthenticationHeaderAsync(scopes);
var response = await _client.SendAsync(request);
response.EnsureSuccessStatusCode();
return response;
}
public async Task<HttpResponseMessage> PutAsync(string endpoint, HttpContent content,
IEnumerable<string> scopes = null)
{
using var request = new HttpRequestMessage(HttpMethod.Put, endpoint);
request.Headers.Authorization = await GetAuthenticationHeaderAsync(scopes);
request.Content = content;
var response = await _client.SendAsync(request);
response.EnsureSuccessStatusCode();
return response;
}
public async Task<HttpResponseMessage> DeleteAsync(string endpoint, IEnumerable<string> scopes = null,
HttpContent content = null)
{
using var request = new HttpRequestMessage(HttpMethod.Delete, endpoint);
request.Headers.Authorization = await GetAuthenticationHeaderAsync(scopes);
request.Content = content;
var response = await _client.SendAsync(request);
response.EnsureSuccessStatusCode();
return response;
}
}

View File

@ -0,0 +1,85 @@
using System.Net.Http.Json;
using Core.Entities.Wallabag;
using Wallabag.Client.Models;
namespace Wallabag.Client;
public partial class WallabagService
{
public async Task<IEnumerable<WallabagItem>> GetEntries(string format = "json", int limit = 50, bool full = false)
{
var bookmarks = new List<WallabagItem>();
var url = $"/api/entries.{format}?perPage={limit}";
if (!full)
{
url = $"{url}&detail=metadata";
}
var allQuery = await GetJsonAsync<WallabagQuery>(url);
if (allQuery != null && allQuery.Embedded != null && allQuery.Embedded.Items != null && allQuery.Embedded.Items.Count() > 0)
{
bookmarks = allQuery.Embedded.Items;
if (allQuery.Total > limit)
{
while (allQuery.QueryLinks.Next != null && !string.IsNullOrEmpty(allQuery.QueryLinks.Next.Href))
{
// url = allQuery.QueryLinks.Next.Href.Replace(_settings.Url, "");
url = allQuery.QueryLinks.Next.Href.Replace("http://", "https://");
allQuery = await GetJsonAsync<WallabagQuery>(url);
bookmarks.AddRange(allQuery.Embedded.Items);
}
}
}
return bookmarks;
}
public async Task<WallabagItem> GetEntryById(int id, string format = "json")
{
var url = $"/api/entries/{id}.{format}";
var item = await GetJsonAsync<WallabagItem>(url);
return item;
}
public async Task<WallabagItem> AddEntryByUrl(string url, IEnumerable<string> tags = null, string format = "json")
{
var endpoint = $"/api/entries.{format}";
var keyVals = new Dictionary<string, string>();
keyVals.Add("url", url);
if (tags != null && tags.Count() > 0)
{
keyVals.Add("tags", string.Join(",", tags));
}
var content = new FormUrlEncodedContent(keyVals);
var response = await PostWithFormDataAsnyc(endpoint, content);
var item = await response.Content.ReadFromJsonAsync<WallabagItem>();
return item;
}
// private async Task<WallabagEntry> GetBookmarkResultsAsync(int limit = 100, int offset = 0)
// {
// WallabagEntry bookmarkResult = null;
//
// var url = $"/api/bookmarks/";
//
// bookmarkResult = await GetBookmarkResultsAsync(url);
//
// return bookmarkResult;
// }
//
// private async Task<WallabagEntry> GetBookmarkResultsAsync(string url)
// {
// WallabagEntry bookmarkResult = null;
//
// bookmarkResult = await _client.GetFromJsonAsync<WallabagEntry>(url);
//
// return bookmarkResult;
// }
}

View File

@ -0,0 +1,18 @@
using Wallabag.Options;
using Wallabag.Settings;
// ReSharper disable once CheckNamespace
namespace Microsoft.Extensions.DependencyInjection;
public static class ServiceRegistrationExtensions
{
public static IServiceCollection Add_Wallabag_Worker(this IServiceCollection services,
IConfiguration configuration)
{
var configSection = configuration.GetSection(WorkerSettings.Position);
services.Configure<WorkerSettings>(configSection);
services.AddSingleton<SettingsService>();
return services;
}
}

View File

@ -0,0 +1,91 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Core.Entities.Wallabag;
using Core.Handler;
using Linkding.Client;
using Wallabag.Client;
using Wallabag.Settings;
namespace Wallabag.Handler
{
public class LinkdingBookmarkToWallabagHandler : ISyncTaskHandler<WallabagService>
{
public Type HandlerType { get; } = typeof(WallabagService);
public string Command { get; } = "LinkdingBookmarkToWallabag";
public async Task ProcessAsync(IEnumerable<WallabagItem> items, WallabagService destinationService,
ILinkdingService linkdingService,
ILogger logger, IConfiguration configuration)
{
var wallabagsNormalized = new Dictionary<int, string>();
var updatedWallabags = new Dictionary<string, IEnumerable<string>>();
var wallabagToRemove = new List<int>();
var linkdingBookmarks = await linkdingService.GetAllBookmarksAsync();
if (linkdingBookmarks != null && linkdingBookmarks.Count() > 0)
{
var settings = SettingsService.Settings;
var tagName = configuration.GetValue<string>("Worker:SyncTag");
linkdingBookmarks =
linkdingBookmarks.Where(x => x.TagNames.Contains(tagName)).OrderBy(x => x.DateAdded);
Regex r = null;
Match m = null;
foreach (var bookmark in linkdingBookmarks)
{
var cleanUrl =
bookmark.Url.Replace(
"?utm_source=share&utm_medium=android_app&utm_name=androidcss&utm_term=2&utm_content=share_button",
"");
// var existingElement = items.FirstOrDefault(x => x.Url.ToLower() == cleanUrl.ToLower());
var existingElement = items.FirstOrDefault(x =>
x.Url.ToLower() == cleanUrl.ToLower() || x.OriginUrl?.ToLower() == cleanUrl.ToLower());
if (existingElement == null)
{
var addToWallabag = true;
foreach (var p in settings.excludedDomains)
{
r = new Regex(p.pattern, RegexOptions.IgnoreCase);
m = r.Match(cleanUrl);
if (m.Success)
{
addToWallabag = false;
break;
}
}
if (addToWallabag && !updatedWallabags.ContainsKey(bookmark.Url))
{
updatedWallabags.Add(cleanUrl,
bookmark.TagNames.Where(x => !x.Equals(tagName, StringComparison.OrdinalIgnoreCase)));
}
}
}
}
else
{
logger.LogInformation($"no bookmarks found");
}
if (updatedWallabags.Count() > 0)
{
logger.LogInformation($"Detected {updatedWallabags.Count()} bookmarks... Start syncing");
foreach (var (url, tags) in updatedWallabags)
{
var result = await destinationService.AddEntryByUrl(url, tags);
if (result.ReadingTime == 0)
{
wallabagToRemove.Add(result.Id);
}
}
logger.LogInformation($"{updatedWallabags.Count()} bookmarks synced");
}
}
}
}

View File

@ -0,0 +1,10 @@
namespace Wallabag.Options;
public class WorkerSettings
{
public const string Position = "Worker";
public int Intervall { get; set; } = 0;
public string SyncTag { get; set; } = "readlater";
}

13
src/Wallabag/Program.cs Normal file
View File

@ -0,0 +1,13 @@
using Wallabag;
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureServices((ctx, services) =>
{
services.Add_Wallabag_HttpClient(ctx.Configuration);
services.Add_Linkding_HttpClient(ctx.Configuration);
services.Add_Wallabag_Worker(ctx.Configuration);
services.AddHostedService<Worker>();
})
.Build();
await host.RunAsync();

View File

@ -0,0 +1,11 @@
{
"profiles": {
"WallabagWorker": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,60 @@
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace Wallabag.Settings;
public class SettingYaml
{
public List<ExcludedDomainPattern> excludedDomains { get; set; } = new ();
}
public class ExcludedDomainPattern
{
public string name { get; set; }
public string pattern { get; set; }
}
public class SettingsService
{
private const string fileName = "data/config.yml";
private static SettingYaml _settings = null;
public static SettingYaml Settings
{
get
{
if (_settings == null)
{
Initialize();
}
return _settings;
}
private set
{
_settings = value;
}
}
private static void Initialize()
{
var filePath = Path.Combine(Environment.CurrentDirectory, fileName);
var fileInfo = new FileInfo(filePath);
if (fileInfo.Exists)
{
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance) // see height_in_inches in sample yml
.Build();
var yml = File.ReadAllText(fileInfo.FullName);
Settings = deserializer.Deserialize<SettingYaml>(yml);
}
else
{
Settings = new SettingYaml();
}
}
}

View File

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-WallabagWorker-D5E52F5F-C642-4BF8-9FD7-8C6C417B0D3A</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.1" />
<PackageReference Include="YamlDotNet" Version="12.3.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Services\Linkding.Client\Linkding.Client.csproj" />
<ProjectReference Include="..\Services\Wallabag.Client\Wallabag.Client.csproj" />
</ItemGroup>
</Project>

96
src/Wallabag/Worker.cs Normal file
View File

@ -0,0 +1,96 @@
using Core.Abstraction;
using Core.Entities.Linkding;
using Core.Handler;
using Linkding.Client;
using Linkding.Client.Options;
using Microsoft.Extensions.Options;
using Wallabag.Client;
using Wallabag.Client.Options;
using Wallabag.Options;
namespace Wallabag;
public class Worker : BackgroundService
{
private readonly IHostApplicationLifetime _hostApplicationLifetime;
private readonly ILogger<Worker> _logger;
private readonly LinkdingService _linkdingService;
private readonly WallabagService _wallabagService;
private readonly LinkdingSettings _linkdingSettings;
private readonly WallabagSettings _wallabagSettings;
private readonly WorkerSettings _settings;
private readonly IConfiguration _configuration;
public Worker(ILogger<Worker> logger, LinkdingService linkdingService, WallabagService wallabagService,
IOptions<LinkdingSettings> linkdingSettings, IOptions<WorkerSettings> settings, IOptions<WallabagSettings> wallabagSettings, IConfiguration configuration, IHostApplicationLifetime hostApplicationLifetime)
{
_logger = logger;
_linkdingService = linkdingService;
_wallabagService = wallabagService;
_configuration = configuration;
_hostApplicationLifetime = hostApplicationLifetime;
_wallabagSettings = wallabagSettings.Value;
_settings = settings.Value;
_linkdingSettings = linkdingSettings.Value;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
await RunSyncWallabag();
int delay = _settings.Intervall * 60000;
if (delay > 0)
{
_logger.LogInformation($"Worker paused for: {_settings.Intervall} minutes");
await Task.Delay(delay, stoppingToken);
}
else
{
_logger.LogInformation($"Delay was set to '0' --> stopping worker");
_hostApplicationLifetime.StopApplication();
}
}
}
public async Task RunSyncWallabag()
{
if (!string.IsNullOrEmpty(_wallabagSettings.Url))
{
_logger.LogInformation($"Starting updating bookmarks for {_linkdingSettings.Url}");
_logger.LogInformation("Collectin LinkdingService Handler");
var wallabagHandlers = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(s => s.GetTypes())
.Where(p => typeof(ISyncTaskHandler<WallabagService>).IsAssignableFrom(p) && p.IsClass);
if (wallabagHandlers != null && wallabagHandlers.Count() > 0)
{
var wallabags = await _wallabagService.GetEntries();
foreach (var handler in wallabagHandlers)
{
ISyncTaskHandler<WallabagService> handlerInstance = null;
try
{
handlerInstance = (ISyncTaskHandler<WallabagService>) Activator.CreateInstance(handler);
await handlerInstance.ProcessAsync(wallabags, _wallabagService, _linkdingService, _logger, _configuration);
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
}
_logger.LogInformation($"no bookmarks found in {_linkdingSettings.Url}");
}
}
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

7
src/Wallabag/config.yml Normal file
View File

@ -0,0 +1,7 @@
excludedDomains:
- name: youtube
pattern: https://[[a-zA-Z0-9]+\.]?(youtube)\.com(?:/.*)?
- name: ebay
pattern: https://[[a-zA-Z0-9]+\.]?(ebay)\.(com|de|fr)(?:/.*)?
- name: amazon
pattern: https://[[a-zA-Z0-9]+\.]?(amazon)\.(com|de|fr)(?:/.*)?