Compare commits
452 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
448057edf6 | ||
|
|
9d993b2992 | ||
|
|
8ca13f6e08 | ||
|
|
80fc4c9538 | ||
|
|
9f8867c1b4 | ||
|
|
481ce883ab | ||
|
|
63f184dd60 | ||
|
|
3d4d9ed111 | ||
|
|
b2ba932735 | ||
|
|
8c1070b318 | ||
|
|
6c4d8f3ede | ||
|
|
f2c118776a | ||
|
|
87890dd249 | ||
|
|
15301c9121 | ||
|
|
8dc4b661d8 | ||
|
|
d47ec4157d | ||
|
|
9d64a59b70 | ||
|
|
234fc20048 | ||
|
|
8432dfdd1f | ||
|
|
5fddfd395e | ||
|
|
11d0e4220f | ||
|
|
6d4e066810 | ||
|
|
9f1274f953 | ||
|
|
9ac1692e2a | ||
|
|
6f88357977 | ||
|
|
19bdbf9aca | ||
|
|
5a25d34ba3 | ||
|
|
13e6199af2 | ||
|
|
5be1e2c954 | ||
|
|
306bd245fc | ||
|
|
7e42d04e36 | ||
|
|
53dfdb3a2e | ||
|
|
eb113a8928 | ||
|
|
c369ef450f | ||
|
|
6674cf99a6 | ||
|
|
70cca405f5 | ||
|
|
d7d211baf2 | ||
|
|
d5a35fa86f | ||
|
|
dc265e57f3 | ||
|
|
4b79b2bd57 | ||
|
|
80c9e54a9a | ||
|
|
eba0b607a4 | ||
|
|
04464a2798 | ||
|
|
202b5192ed | ||
|
|
62397225f9 | ||
|
|
b220fffd6f | ||
|
|
e14f7881a3 | ||
|
|
2085fbe107 | ||
|
|
31a75f044e | ||
|
|
5b74125fcd | ||
|
|
1cf3cfcf77 | ||
|
|
defaf6c1ee | ||
|
|
e0f467fa10 | ||
|
|
362da679b6 | ||
|
|
0b35bcfc3b | ||
|
|
fad162c6c1 | ||
|
|
97fc64fcae | ||
|
|
5224234637 | ||
|
|
b4f1d0ec77 | ||
|
|
5911c43b5b | ||
|
|
4f137466c5 | ||
|
|
3836a2a56c | ||
|
|
49e59d74cc | ||
|
|
9461eea03a | ||
|
|
136939d391 | ||
|
|
33aea82e6e | ||
|
|
4a6511b39f | ||
|
|
6448f05557 | ||
|
|
c08eca7c3b | ||
|
|
df6ca2e17b | ||
|
|
c39a5f9708 | ||
|
|
46d69c31d1 | ||
|
|
e924d121bf | ||
|
|
2fa1dcccac | ||
|
|
dc9413fa7e | ||
|
|
1b34bc30c7 | ||
|
|
4d33f087df | ||
|
|
81a4d5fadb | ||
|
|
7682ae0425 | ||
|
|
677274adb3 | ||
|
|
83fca9fd94 | ||
|
|
4150a9740b | ||
|
|
f7021559d0 | ||
|
|
00d4c550f1 | ||
|
|
97903993c8 | ||
|
|
3d858ed9ab | ||
|
|
cc5cd50065 | ||
|
|
0c0d909dcb | ||
|
|
8dafaa13c0 | ||
|
|
05385ba73b | ||
|
|
924a2c3381 | ||
|
|
6502c424e2 | ||
|
|
243124a4ba | ||
|
|
4a2a14bc05 | ||
|
|
976328c9dc | ||
|
|
3b0086e75e | ||
|
|
7c38a748b6 | ||
|
|
4d98689304 | ||
|
|
8307822e20 | ||
|
|
f1548dc8ea | ||
|
|
25b33ab4d3 | ||
|
|
4af98dcab5 | ||
|
|
3cf916c845 | ||
|
|
bcac05ca20 | ||
|
|
07c61450c7 | ||
|
|
43d95462e0 | ||
|
|
c091e0b9bb | ||
|
|
18c70ffe3e | ||
|
|
ff470028be | ||
|
|
8463188336 | ||
|
|
2808c36753 | ||
|
|
05b1fdd7f2 | ||
|
|
00017e8065 | ||
|
|
ff34112532 | ||
|
|
4cadd1597e | ||
|
|
e30d2cbbf1 | ||
|
|
0449cf824d | ||
|
|
32d899dbfb | ||
|
|
9ec9c55c3f | ||
|
|
01b2b53fd5 | ||
|
|
be6888f8c8 | ||
|
|
c1ed0647fe | ||
|
|
1bf42ceb55 | ||
|
|
ca8bd8d271 | ||
|
|
bf23a4920f | ||
|
|
cfb048ec19 | ||
|
|
5284bea0f0 | ||
|
|
1244a4058c | ||
|
|
8c51363651 | ||
|
|
893f18c7ad | ||
|
|
909c97feda | ||
|
|
fea989c1f8 | ||
|
|
a64ed405f3 | ||
|
|
ffe6cd0a65 | ||
|
|
e0b27317f3 | ||
|
|
7ed619cf2e | ||
|
|
d55f53be0a | ||
|
|
55eed706fb | ||
|
|
2f71ec68d8 | ||
|
|
a45ab2f017 | ||
|
|
26b4da9eb4 | ||
|
|
4897f6ad69 | ||
|
|
bbe8683d59 | ||
|
|
072be78991 | ||
|
|
3f41e62e21 | ||
|
|
ec01336c21 | ||
|
|
5dcfe5c874 | ||
|
|
72f1bc2d77 | ||
|
|
54685c8184 | ||
|
|
3532306833 | ||
|
|
442b065834 | ||
|
|
53e70c118c | ||
|
|
a5575992d6 | ||
|
|
df0244095f | ||
|
|
337f05f60e | ||
|
|
4f8e898440 | ||
|
|
7991b9b27c | ||
|
|
ef42e1956c | ||
|
|
27b3dde6f6 | ||
|
|
cbe557507f | ||
|
|
7a9da5f219 | ||
|
|
d944a70063 | ||
|
|
c20f970405 | ||
|
|
49bb7f3a34 | ||
|
|
021827ff2d | ||
|
|
97312aecf5 | ||
|
|
f3c9a77989 | ||
|
|
2c031bc093 | ||
|
|
69147c2480 | ||
|
|
22ca0045bc | ||
|
|
af431a4766 | ||
|
|
5a8045131b | ||
|
|
7c0a6f1d02 | ||
|
|
4fc69ef7c6 | ||
|
|
91e502deb6 | ||
|
|
1ee191220f | ||
|
|
a96ccf8422 | ||
|
|
57641ecd7d | ||
|
|
ed431aa30f | ||
|
|
7a111bb173 | ||
|
|
220be06631 | ||
|
|
d8c14f6712 | ||
|
|
e514b1be4f | ||
|
|
5e7ef1d25a | ||
|
|
c2b3eb4ac2 | ||
|
|
1bd8370864 | ||
|
|
8c952953e3 | ||
|
|
c065c9b30f | ||
|
|
a18eb1158e | ||
|
|
08ef352288 | ||
|
|
25297b8b0b | ||
|
|
ea591fe707 | ||
|
|
b0bcf8605c | ||
|
|
b28921d6c2 | ||
|
|
87ae7585e8 | ||
|
|
b39b37fb2f | ||
|
|
cd9e0de9dd | ||
|
|
5107849909 | ||
|
|
5c988960f5 | ||
|
|
44792778aa | ||
|
|
0a7bd68258 | ||
|
|
30118eceaa | ||
|
|
87512eb13b | ||
|
|
9919e1a49e | ||
|
|
d04a21b70f | ||
|
|
fc6e0a577f | ||
|
|
0c659422b7 | ||
|
|
2018023dc7 | ||
|
|
04645cab71 | ||
|
|
bec082c0ae | ||
|
|
83b244a8a3 | ||
|
|
ae261381aa | ||
|
|
b088b79ee8 | ||
|
|
0b59ee8634 | ||
|
|
f62b755903 | ||
|
|
04036ef35c | ||
|
|
e4a2ca2d19 | ||
|
|
89f452f3fc | ||
|
|
843f8e0d39 | ||
|
|
8618306230 | ||
|
|
34a3e17326 | ||
|
|
5af49dfc6f | ||
|
|
837d3060b8 | ||
|
|
2754f78083 | ||
|
|
1b509ad60b | ||
|
|
82e923b181 | ||
|
|
e92b8400c7 | ||
|
|
1ad6a80682 | ||
|
|
f355b82bba | ||
|
|
66567b6d6f | ||
|
|
d9582436d4 | ||
|
|
31c75ea0b1 | ||
|
|
057fc10565 | ||
|
|
bbb07c9963 | ||
|
|
dd09d3af2f | ||
|
|
ccb5f8418b | ||
|
|
55d2605222 | ||
|
|
7f49979bc9 | ||
|
|
b529d5b125 | ||
|
|
f2e36d9974 | ||
|
|
ca2b509d54 | ||
|
|
0ba0674b9e | ||
|
|
a2b09fdfec | ||
|
|
fa0bef5500 | ||
|
|
92d148b03d | ||
|
|
3d935e29eb | ||
|
|
3749c9f21d | ||
|
|
2a3d3e46c2 | ||
|
|
9bc1561e01 | ||
|
|
d987246062 | ||
|
|
c6a22e2d96 | ||
|
|
42f904f1ea | ||
|
|
f92521c93a | ||
|
|
041183f914 | ||
|
|
857b6ddc55 | ||
|
|
f2d2b4c4f2 | ||
|
|
8725edaf7e | ||
|
|
4eace705e3 | ||
|
|
b2d71acd93 | ||
|
|
e424fcfe53 | ||
|
|
eefb40cb1f | ||
|
|
785cd3aea9 | ||
|
|
07c700f666 | ||
|
|
8db0928724 | ||
|
|
b25ba9de27 | ||
|
|
bf478a6706 | ||
|
|
a1a58f51b1 | ||
|
|
8850161641 | ||
|
|
f931148594 | ||
|
|
c31b0bcf17 | ||
|
|
ba260ac608 | ||
|
|
f2fc19b168 | ||
|
|
ceabc666c3 | ||
|
|
6c217c2450 | ||
|
|
7ff8921420 | ||
|
|
fe56c88309 | ||
|
|
b13b91c8e8 | ||
|
|
d71435df53 | ||
|
|
058758122c | ||
|
|
759cfd77e9 | ||
|
|
37268b873a | ||
|
|
439a04c603 | ||
|
|
9f642d501a | ||
|
|
7f8e2c66a6 | ||
|
|
9962fd4a55 | ||
|
|
c27343534f | ||
|
|
46ecb2cf70 | ||
|
|
88aa3f38c2 | ||
|
|
9214da3756 | ||
|
|
41e5152771 | ||
|
|
6b01bd7ffe | ||
|
|
76a9f8e3df | ||
|
|
e15941520f | ||
|
|
ff7404d01d | ||
|
|
560540618c | ||
|
|
bfa82f6bb3 | ||
|
|
f77cd1c752 | ||
|
|
8461b0c15d | ||
|
|
2938a0f122 | ||
|
|
beccd1a7d1 | ||
|
|
c6883908f1 | ||
|
|
36d8547f78 | ||
|
|
b01c91c0a3 | ||
|
|
3712b2e93f | ||
|
|
0ddc1131dc | ||
|
|
f4a941cd93 | ||
|
|
db72f2a722 | ||
|
|
16023676ea | ||
|
|
52e85835df | ||
|
|
fe63770d21 | ||
|
|
bd5442c9e5 | ||
|
|
c7554f2b8d | ||
|
|
63f9cd0bac | ||
|
|
c537d0f396 | ||
|
|
e759aa8d1b | ||
|
|
2d8664b3ce | ||
|
|
16076ebfb6 | ||
|
|
38ca447c8a | ||
|
|
f91006631d | ||
|
|
3cc5e4cc7a | ||
|
|
75178b7874 | ||
|
|
ce3f6dae20 | ||
|
|
bd82392d71 | ||
|
|
836a3d353a | ||
|
|
a4d2a9ea6f | ||
|
|
b0fc8d63a6 | ||
|
|
ec7781fb9f | ||
|
|
787e1a46d1 | ||
|
|
148d46fcbc | ||
|
|
7e1a676e0a | ||
|
|
e9220c5e77 | ||
|
|
72d41d4883 | ||
|
|
de6c3e9b45 | ||
|
|
a0ab507ed1 | ||
|
|
3c76d0e7e3 | ||
|
|
34127d78bf | ||
|
|
ca33f488f1 | ||
|
|
717aac9dc8 | ||
|
|
4b07dbadb2 | ||
|
|
9bbebd2085 | ||
|
|
d60b29e0a2 | ||
|
|
80d2dfd9a4 | ||
|
|
464454e7a6 | ||
|
|
d145b65237 | ||
|
|
912198cc40 | ||
|
|
bffab79ba7 | ||
|
|
c82f7fd9ca | ||
|
|
53afe528e8 | ||
|
|
225befe57e | ||
|
|
40817e73b4 | ||
|
|
17c93c2172 | ||
|
|
c8d7ccadb7 | ||
|
|
7bb292404a | ||
|
|
d2ac0ed53f | ||
|
|
c633a56bfb | ||
|
|
34696ba3a8 | ||
|
|
93c105fe86 | ||
|
|
29eac0b4e5 | ||
|
|
fc27078954 | ||
|
|
759a8e84f0 | ||
|
|
b2c7da319e | ||
|
|
b56095224a | ||
|
|
2f12a8cc93 | ||
|
|
140f85f214 | ||
|
|
7405c7e20f | ||
|
|
f29b13679a | ||
|
|
9b9ed53cf9 | ||
|
|
2447ae5cff | ||
|
|
7379b8b521 | ||
|
|
22473823c9 | ||
|
|
d8bb9950d3 | ||
|
|
279fe2ae18 | ||
|
|
1e812f3f06 | ||
|
|
0f1401eb71 | ||
|
|
57e739c3ef | ||
|
|
1b457585d9 | ||
|
|
c9eab0b01e | ||
|
|
3da5074d14 | ||
|
|
e97a476c7f | ||
|
|
a716f31b17 | ||
|
|
b2e760a284 | ||
|
|
7cc48aeb7f | ||
|
|
a6494b4226 | ||
|
|
5ed03e29f3 | ||
|
|
5c07148eb2 | ||
|
|
17b4a7be4d | ||
|
|
9b08ebbbd5 | ||
|
|
37c469dbc7 | ||
|
|
c08033d153 | ||
|
|
9c63f621a8 | ||
|
|
c3cb12e5e2 | ||
|
|
d3799bc30a | ||
|
|
cb8933b493 | ||
|
|
1aa58c047f | ||
|
|
52b3084bee | ||
|
|
70b05c9e07 | ||
|
|
cafa440aee | ||
|
|
681cb7226a | ||
|
|
b36e49e43f | ||
|
|
90a6c2d98d | ||
|
|
21d21846ca | ||
|
|
cc8d7d9912 | ||
|
|
10527afbda | ||
|
|
d16ece2a81 | ||
|
|
e7f7dc8d60 | ||
|
|
2f2943452f | ||
|
|
08ae2b5351 | ||
|
|
eda1a1c4dd | ||
|
|
238abdb567 | ||
|
|
c3f9ceb03a | ||
|
|
b95e9fcda3 | ||
|
|
b3f8a2c18c | ||
|
|
18bbf7d86f | ||
|
|
8e3ad6d874 | ||
|
|
7a63ac83d2 | ||
|
|
8dc28ddbee | ||
|
|
15803cf0a3 | ||
|
|
5ac001781c | ||
|
|
66986b88f8 | ||
|
|
f4e12a63a5 | ||
|
|
09b0f34199 | ||
|
|
095c005c3f | ||
|
|
e414b6458d | ||
|
|
ece73784c2 | ||
|
|
8dd9644c13 | ||
|
|
556e8db104 | ||
|
|
ce979b6c84 | ||
|
|
d2f422a441 | ||
|
|
e4daff8956 | ||
|
|
c146c311c1 | ||
|
|
2e1b17dcdf | ||
|
|
2ef11f56e5 | ||
|
|
fdaeca78a7 | ||
|
|
93e7343fe9 | ||
|
|
5167608f7a | ||
|
|
7fe3a8520d | ||
|
|
d4a0881e77 | ||
|
|
7f42210f2f | ||
|
|
33497bc92d | ||
|
|
06aa2e6c07 | ||
|
|
3bffaa01dc | ||
|
|
66237c35ea | ||
|
|
f532857345 | ||
|
|
5936e1a68b | ||
|
|
17b3b2b770 | ||
|
|
826e805b06 | ||
|
|
5419d76606 | ||
|
|
0d84653f7d | ||
|
|
b7da56ac42 | ||
|
|
7140788b48 | ||
|
|
a43bd5a50e | ||
|
|
81dd98e83b |
9
.github/workflows/build_linux.yml
vendored
@@ -32,12 +32,15 @@ jobs:
|
||||
path: novelwriter/assets
|
||||
|
||||
- name: Build AppImage
|
||||
run: python pkgutils.py build-appimage --linux-tag $LINUX_TAG --python-version $PYTHON_VERSION
|
||||
id: build
|
||||
run: |
|
||||
echo "BUILD_VERSION=$(python pkgutils.py version)" >> $GITHUB_OUTPUT
|
||||
python pkgutils.py build-appimage --linux-tag $LINUX_TAG --python-version $PYTHON_VERSION
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Linux-AppImage
|
||||
path: dist_appimage
|
||||
name: novelWriter-${{ steps.build.outputs.BUILD_VERSION }}-Linux-AppImage
|
||||
path: dist_appimage/*.AppImage*
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
23
.github/workflows/build_mac.yml
vendored
@@ -8,8 +8,7 @@ jobs:
|
||||
|
||||
buildMac-AMD64:
|
||||
needs: buildAssets
|
||||
# Stay on macos-12 due to https://github.com/create-dmg/create-dmg/issues/143
|
||||
runs-on: macos-12
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
PYTHON_VERSION: "3.12"
|
||||
PACKAGE_ARCH: x86_64
|
||||
@@ -25,19 +24,22 @@ jobs:
|
||||
path: novelwriter/assets
|
||||
|
||||
- name: Build App Bundle
|
||||
run: ./setup/macos/build.sh $PYTHON_VERSION $PACKAGE_ARCH $MINICONDA_ARCH
|
||||
id: build
|
||||
run: |
|
||||
echo "BUILD_VERSION=$(python pkgutils.py version)" >> $GITHUB_OUTPUT
|
||||
./setup/macos/build.sh $PYTHON_VERSION $PACKAGE_ARCH $MINICONDA_ARCH
|
||||
|
||||
- name: Upload DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: MacOS-AMD64-DMG
|
||||
path: dist_macos
|
||||
name: novelWriter-${{ steps.build.outputs.BUILD_VERSION }}-MacOS-AMD64-DMG
|
||||
path: dist_macos/*.dmg*
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
buildMac-M1:
|
||||
needs: buildAssets
|
||||
runs-on: macos-14
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
PYTHON_VERSION: "3.12"
|
||||
PACKAGE_ARCH: aarch64
|
||||
@@ -53,12 +55,15 @@ jobs:
|
||||
path: novelwriter/assets
|
||||
|
||||
- name: Build App Bundle
|
||||
run: ./setup/macos/build.sh $PYTHON_VERSION $PACKAGE_ARCH $MINICONDA_ARCH
|
||||
id: build
|
||||
run: |
|
||||
echo "BUILD_VERSION=$(python pkgutils.py version)" >> $GITHUB_OUTPUT
|
||||
./setup/macos/build.sh $PYTHON_VERSION $PACKAGE_ARCH $MINICONDA_ARCH
|
||||
|
||||
- name: Upload DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: MacOS-M1-DMG
|
||||
path: dist_macos
|
||||
name: novelWriter-${{ steps.build.outputs.BUILD_VERSION }}-MacOS-M1-DMG
|
||||
path: dist_macos/*.dmg*
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
31
.github/workflows/build_win.yml
vendored
@@ -26,12 +26,37 @@ jobs:
|
||||
path: novelwriter/assets
|
||||
|
||||
- name: Build Setup Installer
|
||||
run: python pkgutils.py build-win-exe
|
||||
id: build
|
||||
run: |
|
||||
echo "BUILD_VERSION=$(python pkgutils.py version)" >> $Env:GITHUB_OUTPUT
|
||||
python pkgutils.py build-win-exe
|
||||
|
||||
- name: Upload Artifacts
|
||||
- name: Upload Unsigned Artifacts
|
||||
id: upload-unsigned-artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Win-Setup
|
||||
name: novelWriter-${{ steps.build.outputs.BUILD_VERSION }}-Win-Setup
|
||||
path: dist/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Submit Signing Request
|
||||
uses: signpath/github-action-submit-signing-request@v1
|
||||
with:
|
||||
api-token: "${{ secrets.SIGNPATH_API_TOKEN }}"
|
||||
organization-id: "0471e52c-66fa-4e9a-bfb9-36167095ca3f"
|
||||
project-slug: "novelWriter"
|
||||
signing-policy-slug: "release-signing "
|
||||
github-artifact-id: "${{ steps.upload-unsigned-artifact.outputs.artifact-id }}"
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: "dist/"
|
||||
parameters: |
|
||||
version: "${{ steps.build.outputs.BUILD_VERSION }}"
|
||||
|
||||
- name: Upload Signed Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: novelWriter-${{ steps.build.outputs.BUILD_VERSION }}-Win-Setup-Signed
|
||||
path: dist/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
4
.github/workflows/test_linux.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
testLinux:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||
fail-fast: false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -41,6 +41,6 @@ jobs:
|
||||
export QT_QPA_PLATFORM=offscreen
|
||||
python -m pytest -v --cov=novelwriter --timeout=60
|
||||
- name: Upload to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v5
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
2
.github/workflows/test_mac.yml
vendored
@@ -32,6 +32,6 @@ jobs:
|
||||
export QT_QPA_PLATFORM=offscreen
|
||||
python -m pytest -v --cov=novelwriter --timeout=60
|
||||
- name: Upload to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v5
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
2
.github/workflows/test_win.yml
vendored
@@ -28,6 +28,6 @@ jobs:
|
||||
run: |
|
||||
python -m pytest -v --cov=novelwriter --timeout=60
|
||||
- name: Upload to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v5
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
305
CHANGELOG.md
@@ -1,5 +1,310 @@
|
||||
# novelWriter Changelog
|
||||
|
||||
## Version 2.6 [2025-01-26]
|
||||
|
||||
### Release Notes
|
||||
|
||||
The 2.6 release fixes a few minor issues from the RC 1 pre-release, and updates most translations.
|
||||
|
||||
See the website for complete [Release Notes](https://novelwriter.io/releases/release_2_6.html).
|
||||
|
||||
### Detailed Changelog
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
* Fixed a bug in the novel selector dropdown used a few places that would clear the selection if
|
||||
the novel list was refreshed. PR #2179.
|
||||
* Fixed a bug that would crash the app if the project tree was clicked without any project having
|
||||
been loaded. The selection model does not exist prior to a project load. Issue #2173. PR #2181.
|
||||
* Replaced a broken link on the dictionary install tool available on Windows. PR #2189.
|
||||
* Backported a few code fixes from 2.7 related to freeing memory from closed dialogs and one
|
||||
instance of a potentially uninitialised variable. PR #2202.
|
||||
* Fixed word wrapping in the item details panel for status and importance labels with very long
|
||||
names. PR #2202.
|
||||
* Fixed the project tree context menu so that it is not opened when right-clicking on an empty area
|
||||
of the tree widget. PR #2202.
|
||||
|
||||
**Improvements**
|
||||
|
||||
* Chapter headings are now level 1 headings in all manuscript output formats, not just HTML like
|
||||
before. Most applications that post-processes output will assume chapters are level 1.
|
||||
Correspondingly, scenes are now level 2 and sections are level 3. Notes headings remain
|
||||
unchanged. Issue #2205. PR #2206.
|
||||
|
||||
**Internationalisation**
|
||||
|
||||
* German, US English, Italian, Japanese, Norwegian and Polish have been updated in full. Brazilian
|
||||
Portuguese and Chinese have been partially updated. PR #2207.
|
||||
|
||||
**Documentation**
|
||||
|
||||
* Documentation has been updated with 2.6 features. PR #2210.
|
||||
|
||||
----
|
||||
|
||||
## Version 2.6 RC 1 [2025-01-09]
|
||||
|
||||
### Release Notes
|
||||
|
||||
This is a release candidate of the next release version, and is intended for testing purposes.
|
||||
Please be careful when using this version on live writing projects, and make sure you take frequent
|
||||
backups.
|
||||
|
||||
### Detailed Changelog
|
||||
|
||||
**New Features**
|
||||
|
||||
* The session timer in the status bar can now be hidden by clicking on it. Issue #1029. PR #2149.
|
||||
* Status and importance labels can now be exported and imported. Issue #1847. PRs #2152 and #2153.
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
* Fix a beta release bug where it was not possible to change the status or importance flag on
|
||||
folders in the project tree. Issue #2145. PR #2147.
|
||||
* Fix an issue where the word counter would override the selection counter in the editor. This was
|
||||
caused by the selection counter being on a much shorter timer than the word counter, so in some
|
||||
cases the latter result came in after the former even if the user actions where the other way
|
||||
around. The selection counter now takes precedence, always. Issue #2155. PRs #2156 and #2157.
|
||||
* Fix a minor issue where the search panel header background would not update colour when the theme
|
||||
was changed. Issue #2162. PR #2167.
|
||||
|
||||
**Improvements**
|
||||
|
||||
* It is no longer necessary to click the "Apply" button when making changes to status and
|
||||
importance labels, or auto-replace settings, in Project Settings. The changes are applied
|
||||
directly to the listed items. They are not applied to the project until the Project Settings are
|
||||
accepted. This is a more intuitive approach. Issue #2150. PR #2151.
|
||||
|
||||
**Packaging and Installation**
|
||||
|
||||
* MacOS packages are now built on the latest MacOS image available on GitHub. The x86_64 build was
|
||||
previously locked to MacOS 12, which is no loner available. PR #2141.
|
||||
|
||||
----
|
||||
|
||||
## Version 2.6 Beta 2 [2024-12-23]
|
||||
|
||||
### Release Notes
|
||||
|
||||
This is a beta release of the next release version, and is intended for testing purposes. Please be
|
||||
careful when using this version on live writing projects, and make sure you take frequent backups.
|
||||
|
||||
### Detailed Changelog
|
||||
|
||||
**New Features**
|
||||
|
||||
* The project tree has been completely rewritten under the hood. The main difference in the GUI is
|
||||
a lifting of restrictions on drag and drop. You can now select multiple items in multiple places
|
||||
in the tree, and drag them to a new location. It is still not allowed to drag root level items,
|
||||
or drop items on the root level. PR #2119.
|
||||
* You can now drag and drop items from the project tree onto the editor and viewer to load the
|
||||
documents there. PR #2119.
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
* Make line height in DocX scale correctly. Issue #2099. PR #2102.
|
||||
* Make margins scale with font size in ODT and HTML output. PR #2102.
|
||||
* Fix DocX page count to work with Office 365. PR #2102.
|
||||
* Fix font scaling issues for PDF output. Issue #2100. PR #2104.
|
||||
* Fix an issue that cleared the editor when the return key was used on a folder item. PR #2113.
|
||||
* Fix an issue where the viewer and preview would not reset text emphasis when selecting a default
|
||||
font style with emphasis features. Issue #2121. PR #2122.
|
||||
* Change how custom fonts are loaded to ensure that the Qt framework don't take shortcuts by using
|
||||
a similar font instead of the chosen font. This is a side effect of the Qt font matching feature.
|
||||
Issue #2118. PR #2122.
|
||||
|
||||
**Improvements**
|
||||
|
||||
* Move processing of alternative apostrophe and dash to a later point in the tokenization process.
|
||||
It needs to happen after dialogue is processed to work as intended. PR #2095.
|
||||
* Use icons for text margin settings in Build Settings. Issue #2098. PR #2102.
|
||||
* Replace the toolbar icon in the editor. PR #2103.
|
||||
* Turn off auto-default button on the Build dialog. Issue #2101. PR #2105.
|
||||
* Add ANSI colours to log output for easier debugging. PRs #2114 and #2117.
|
||||
* Document header is now aligned to the bottom of the top page margin in the same way for ODT and
|
||||
DocX output. Issue #2120. PR #2128.
|
||||
* Dialogue detection for quoted dialogue has been improved to better support Chinese, and other
|
||||
languages that don't use whitespaces to separate words. Some improvements have also been made to
|
||||
how single quoted dialogues are processed when apostrophes are present. Issue #2131. PR #2138.
|
||||
|
||||
**Code Improvements**
|
||||
|
||||
* Completely rewrite the project tree into a model/view set with one single definition if the
|
||||
project structure. PR #2109.
|
||||
* The auto-replace feature in the editor has been refactored. It is now better separated from the
|
||||
editor code, and may have some minor performance improvements. Issue #2129. PR #2132.
|
||||
|
||||
----
|
||||
|
||||
## Version 2.6 Beta 1 [2024-11-12]
|
||||
|
||||
### Release Notes
|
||||
|
||||
This is a beta release of the next release version, and is intended for testing purposes. Please be
|
||||
careful when using this version on live writing projects, and make sure you take frequent backups.
|
||||
|
||||
### Detailed Changelog
|
||||
|
||||
**New Features**
|
||||
|
||||
* Added PDF build format. Issue #2046. PR #2048.
|
||||
* Added DocX (MS Word) format. Issue #1537. PR #2056.
|
||||
* The Build settings tool has the following changes: Ability to control page break and centring of
|
||||
main titles, an option to disable styling for headings altogether, and the ODT section has been
|
||||
generalised to apply to all document formats. Issue #2023. PR #2045.
|
||||
* Text and heading margin can now be control in the Manuscript Build tool. Issue #2023. PR #2051.
|
||||
* Word counts and other such statistics can now be inserted into the manuscript. This is done
|
||||
through new insert actions in the Insert menu, and processed at build time. Issue #2024.
|
||||
PR #2073.
|
||||
* Forced line breaks can now be inserted as a shortcode. This is useful when line breaks in
|
||||
paragraphs are ignored when building the manuscript. Issue #1991. PR #2063.
|
||||
* A new `@mentions` keyword has been added that can list any tag in the project as being mentioned
|
||||
in the current section. These are listed separately from tags present in the story section.
|
||||
Issue #1822. PR #2064.
|
||||
* Tags in novel documents can now be referenced with the `@story` keyword. Issue #1990. PR #2072.
|
||||
* URLs starting with http/https are now recognised and clickable in the editor, viewer, and
|
||||
manuscript preview. Issue #1426. PR #2067.
|
||||
* Added dialogue highlighting for Portuguese and Polish dialogue styles. Issues #2066 and #2070.
|
||||
Related to #1773. PRs #2068, #2079, #2081 and #2082.
|
||||
* Four dashes in the editor are now replaced by a horizontal bar. This is useful as a replacement
|
||||
symbol for em dash when using dialogue highlighting to avoid triggering it. The horizontal bar is
|
||||
replaced with an em dash in the manuscript. Issue #2070. PR #2079.
|
||||
* Manuscript Build settings can now be duplicated. Issue #1931. PR #2084.
|
||||
* Page breaks can optionally be made visible in the Manuscript preview. PR #2086.
|
||||
* The document status label can now be selected in the Outline View. Issue #1909. PR #2088.
|
||||
* The document viewer now has an edit button to open the document in the editor. Issue #1963.
|
||||
PR #2089.
|
||||
|
||||
**User Interface**
|
||||
|
||||
* The Manuscript Build settings have now been moved into a single scrollable page, using the same
|
||||
layout as in Preferences. The heading and filtering pages are still separate forms, as they are
|
||||
of a more complex nature. PR #2031.
|
||||
* The currently open document in the editor is now highlighted in the project tree, just like it is
|
||||
in the novel view. Issue #1981. PR #2077.
|
||||
* The details panel in the Outline view is now resizable. PR #2078.
|
||||
|
||||
**Improvements**
|
||||
|
||||
* Add an option to ignore a word in the spell checker. An ignored word is ignored only for the
|
||||
current writing session. PR #2030.
|
||||
* Word counts and other such statistics is now exported as custom variables to ODT files, and can
|
||||
be inserted in the text in LibreOffice, and similar. Issue #2033. PR #2035.
|
||||
|
||||
**Code Improvements**
|
||||
|
||||
* Added Python 3.13 coverage to ensure the new Python release is supported. Issue #2040. PR #2044.
|
||||
* Preparation for Qt6: Clean up Qt enums. PR #2025.
|
||||
* Preparation for Qt6: Replace Qt regular expression tools with the internal Python re module for
|
||||
markup processing and syntax highlighting. It is both slightly faster, and there are issues with
|
||||
text encoding in at least some versions of Qt6 or PyQt6. PRs #2028 and #2043.
|
||||
* Preparation for Qt6: Added a wrapper function for connecting signals to slots that has a
|
||||
different function signature. Python lambdas generate warnings inn Qt6. PR #2075.
|
||||
* Refactored manuscript formats and moved most of the processing to the Tokenizer class to simplify
|
||||
the format classes and also make them more consistent. PRs #2060, #2061 and #2062.
|
||||
* The document builder has been refactored to support more generalised format classes. PR #2047.
|
||||
* Action parent assignment has been rafactored in the main menu. #2075.
|
||||
* Raw text format output has been refactored and reduced to just appending documents. PR #2087.
|
||||
|
||||
----
|
||||
|
||||
## Version 2.5.3 [2024-11-26]
|
||||
|
||||
### Release Notes
|
||||
|
||||
This is a patch release that fixes a few minor bugs in the user interface, and with the HTML
|
||||
manuscript format. This patch also adds a Russian translation, and updated the German and
|
||||
Portuguese translations.
|
||||
|
||||
### Detailed Changelog
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
* Fixed the red/green LEDs on the statusbar for the Default Light theme. The colours were swapped.
|
||||
Issue #2057. PR #2059.
|
||||
* Moved the styles tag into the head tag for HTML manuscript output, which is the correct placement
|
||||
for them. Issue #2080. PR #2085.
|
||||
* Fixed a bug in the project tree where it was possible to drag content to root level on some Qt
|
||||
versions where the disallow flag is not obeyed. The illegal action is now additionally blocked by
|
||||
code. Issue #2108. PR #2109.
|
||||
|
||||
**Documentation**
|
||||
|
||||
* Updated a dead link to the spell check library with the new one. Issue #2042. PR #2059.
|
||||
|
||||
**Internationalisation**
|
||||
|
||||
* Russian translation added by Konstantin Tan (@k1kimosha). PR #2126
|
||||
* Updated German and Portuguese translations. PR #2126
|
||||
|
||||
----
|
||||
|
||||
## Version 2.5.2 [2024-09-17]
|
||||
|
||||
### Release Notes
|
||||
|
||||
This is a patch release that fixes a series of issues mostly affecting dialogue highlighting in the
|
||||
document viewer. The way the text is formatted in the viewer changed a lot in 2.5, and there were a
|
||||
few issues with the new implementation. The tab stop distance setting was also ignored, but has now
|
||||
been fixed as well.
|
||||
|
||||
### Detailed Changelog
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
* Fixed an issue where the tab stop distance setting was being ignored in the document viewer. It
|
||||
must be set again every time a document is loaded. Issue #1996. PR #2014.
|
||||
* Fixed an issue with overlapping formatting markers when processing text into new formats. This
|
||||
issues was introduced when dialogue highlighting was added to the document viewer and manuscript
|
||||
build. It arises because start and end of dialogue can occur on the same character as other
|
||||
formatting, and then the order these markers are processed matters. This was not properly handled
|
||||
in the code. Issue #2012. PR #2014.
|
||||
* Dialogue highlighting is now only applied to plain text paragraphs, not comments, headings, and
|
||||
other places where there should be no dialogue. Issue #2013. PR #2014.
|
||||
* Note documents no longer get dialogue highlighting. Issue #2011. PR #2014.
|
||||
|
||||
**Documentation**
|
||||
|
||||
* Added a section about spell checking in the documentation. PR #2015.
|
||||
|
||||
----
|
||||
|
||||
## Version 2.5.1 [2024-07-28]
|
||||
|
||||
### Release Notes
|
||||
|
||||
This is a patch release that fixes an issue with the lock file being left in the project when the
|
||||
project open is cancelled, a few issues with custom input boxes in Preferences, and a usability
|
||||
issue when selecting to edit a tag or reference from the Tags and References panel as well as fixed
|
||||
an issue with the Importance labels not being updated in the tabs for tags.
|
||||
|
||||
### Detailed Changelog
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
* Fixes an issue where the project lock file was created before certain checks where the user could
|
||||
cancel the project open process, leaving a lock file in the project folder behind. Issue #1977.
|
||||
PR #1978.
|
||||
* Fixes an issue where the user could add spaces to the pad before and after Preference settings,
|
||||
which would pad every space entered into the text. The Preferences settings no longer accept any
|
||||
white spaces in these boxes (they are removed on save) and also strips duplicate character
|
||||
entries. Issue #1985. PR #1986.
|
||||
* The narrator break and dialog symbol Preference text boxes will now be cleared if the settings is
|
||||
a white space. PR #1986.
|
||||
* Fixed an issue where Importance labels were not updated in the viewer panel's tags tabs when they
|
||||
where changed in Project Settings. Issue #1992. PR #1993.
|
||||
|
||||
**Usability**
|
||||
|
||||
* When selecting to edit a tag or reference from the panel below the Document Viewer, the editor
|
||||
now scrolls to the relevant heading as it does in the viewer. Issue #1983. PR #1987.
|
||||
|
||||
**Internationalisation**
|
||||
|
||||
* Latin American Spanish translation updated by Tommy Marplatt (@tmarplatt). PR #1989.
|
||||
|
||||
----
|
||||
|
||||
## Version 2.5 [2024-07-10]
|
||||
|
||||
### Release Notes
|
||||
|
||||
@@ -30,6 +30,7 @@ translators for the languages currently available:
|
||||
* **Norwegian:** Veronica Berglyd Olsen (vkbo)
|
||||
* **Polish:** Anna Maria Polak (Nauthiz)
|
||||
* **Portuguese:** Bruno Meneguello (bkmeneguello)
|
||||
* **Russian:** Konstantin Tan (k1kimosha)
|
||||
* **Simplified Chinese:** Qianzhi Long (longqzh)
|
||||
|
||||
Additional larger translation contributions:
|
||||
|
||||
@@ -29,6 +29,15 @@ documentation.
|
||||
* PyPi Project: [pypi.org/project/novelWriter](https://pypi.org/project/novelWriter)
|
||||
* Social Media: [fosstodon.org/@novelwriter](https://fosstodon.org/@novelwriter)
|
||||
|
||||
## Sponsors
|
||||
|
||||
<table style="border: none;">
|
||||
<tr>
|
||||
<td><img align="left" style="height: 25px;" src="https://raw.githubusercontent.com/vkbo/novelWriter/main/setup/signpath_logo.png"></td>
|
||||
<td>Free code signing on Windows provided by <a href="https://about.signpath.io/">SignPath.io</a>, certificate by <a href="https://signpath.org/">SignPath Foundation</a>.</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Implementation
|
||||
|
||||
novelWriter is written with Python 3 (3.9+) using Qt5 and PyQt5 (5.15 only), and is released on
|
||||
|
||||
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 530 KiB After Width: | Height: | Size: 516 KiB |
|
Before Width: | Height: | Size: 540 KiB After Width: | Height: | Size: 535 KiB |
@@ -13,7 +13,7 @@ Glossary
|
||||
see :ref:`a_proj_roots`.
|
||||
|
||||
Novel Documents
|
||||
These are documents that are created under a "Novel" :term:`root folder`. They behave
|
||||
These are documents that are created under a "Novel" :term:`Root Folder`. They behave
|
||||
differently than :term:`Project Notes`, and have some more restrictions. For instance, they
|
||||
can not exist in folders intended only for project notes. See the :ref:`a_struct` chapter for
|
||||
more details.
|
||||
|
||||
@@ -16,7 +16,7 @@ This is a list of hopefully helpful little tips on how to get the most out of no
|
||||
Managing the Project
|
||||
====================
|
||||
|
||||
.. dropdown:: Create a Project from a Template
|
||||
.. dropdown:: How do I create a project from a template?
|
||||
:animate: fade-in-slide-down
|
||||
|
||||
On the Welcome dialog's **Create New Project** form, you can select to "Prefill Project" from
|
||||
@@ -24,7 +24,7 @@ Managing the Project
|
||||
dedicated to be a template project. If you have a structure and settings you want to use for
|
||||
every new project, this is the best solution.
|
||||
|
||||
.. dropdown:: Merge Multiple Documents Into One
|
||||
.. dropdown:: How do I merge multiple documents into one?
|
||||
:animate: fade-in-slide-down
|
||||
|
||||
If you need to merge a selection of documents in your project into a single document, you can
|
||||
@@ -35,28 +35,18 @@ Managing the Project
|
||||
In the dialog that pops up, the documents will be in the same order as in the folder, but you
|
||||
can rearrange them here of you wish. See :ref:`a_ui_tree_split_merge` for more details.
|
||||
|
||||
.. dropdown:: How do I share status or importance labels between projects?
|
||||
:animate: fade-in-slide-down
|
||||
|
||||
The status or importance labels you have defined in a project can be exported from **Project
|
||||
Settings** from the respective configuration tabs. You can then import these labels in another
|
||||
project.
|
||||
|
||||
|
||||
Layout Tricks
|
||||
=============
|
||||
|
||||
.. dropdown:: Align Paragraphs with Line Breaks
|
||||
:animate: fade-in-slide-down
|
||||
|
||||
If you have line breaks in you paragraphs, and also want to apply additional text alignment or
|
||||
indentation, you must apply the alignment tags to the first line.
|
||||
|
||||
For example, this will centre the two lines.
|
||||
|
||||
.. code-block:: md
|
||||
|
||||
>> Line one is centred. <<
|
||||
Line two is also centred.
|
||||
|
||||
This text is not centred, because it is a new paragraph.
|
||||
|
||||
See :ref:`a_fmt_align` for more details.
|
||||
|
||||
.. dropdown:: Create a Simple Table
|
||||
.. dropdown:: How do I create a table?
|
||||
:animate: fade-in-slide-down
|
||||
|
||||
The formatting tools available in novelWriter don't allow for complex structures like tables.
|
||||
@@ -69,12 +59,22 @@ Layout Tricks
|
||||
This is most suitable for your notes, as the result in exported documents cannot be guaranteed
|
||||
to match. Especially if you don't use the same font in your manuscript as in the editor.
|
||||
|
||||
.. dropdown:: Turn Off First Line Indent for a Paragraph
|
||||
.. dropdown:: How do I force a line break when line breaks are ignored in my manuscript?
|
||||
:animate: fade-in-slide-down
|
||||
|
||||
In the **Manuscript Build Settings** you can choose to ignore line breaks within paragraphs in
|
||||
your text. However, some times you still need those breaks. Like for instance on the cover page
|
||||
where you may need to add your name and address. In such cases, you can add ``[br]`` where you
|
||||
want line breaks. These breaks cannot be ignored by any settings and will always be respected.
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
.. dropdown:: How do I turn off first line indent for a specific paragraph?
|
||||
:animate: fade-in-slide-down
|
||||
|
||||
If you have first line indent enabled, but have a specific paragraph that you don't want
|
||||
indented, you can disable the indentation by explicitly add text alignment. Aligned paragraphs
|
||||
are not indented. For instance by adding ``<<`` to the end to left-align it,
|
||||
indented, you can disable the indentation by explicitly adding text alignment. For instance by
|
||||
adding ``<<`` to the end to left-align it. Aligned paragraphs are not indented.
|
||||
|
||||
See :ref:`a_fmt_align` for more details.
|
||||
|
||||
@@ -82,7 +82,7 @@ Layout Tricks
|
||||
Organising Your Text
|
||||
====================
|
||||
|
||||
.. dropdown:: Add Introductory Text to Chapters
|
||||
.. dropdown:: How do I add introductory text to chapters?
|
||||
:animate: fade-in-slide-down
|
||||
|
||||
Sometimes chapters have a short preface, like a brief piece of text or a quote to set the stage
|
||||
@@ -92,7 +92,7 @@ Organising Your Text
|
||||
such text. Separating chapter and scene files also allows you to make scene files child
|
||||
documents of the chapter.
|
||||
|
||||
.. dropdown:: Distinguishing Soft and Hard Scene Breaks
|
||||
.. dropdown:: How do I distinguishing between soft and hard scene breaks?
|
||||
:animate: fade-in-slide-down
|
||||
|
||||
Depending on your writing style, you may need to separate between soft and hard scene breaks
|
||||
@@ -101,8 +101,8 @@ Organising Your Text
|
||||
In such cases you may want to use different scene headings for hard and soft scene breaks. The
|
||||
**Build Manuscript** tool will let you define a different format for scenes using the ``###``
|
||||
and ``###!`` heading codes when you generate your manuscript. You can for instance add the
|
||||
common "``* * *``" for hard breaks and select to soft scene breaks, which will just insert an
|
||||
empty paragraph in their place. See :ref:`a_manuscript_settings` for more details.
|
||||
common "``* * *``" for hard breaks and select to hide soft scene breaks, which will just insert
|
||||
an empty paragraph in their place. See :ref:`a_manuscript_settings` for more details.
|
||||
|
||||
.. versionadded:: 2.4
|
||||
|
||||
@@ -110,7 +110,7 @@ Organising Your Text
|
||||
Other Tools
|
||||
===========
|
||||
|
||||
.. dropdown:: Convert Project to/from yWriter Format
|
||||
.. dropdown:: How do I convert my project to/from the yWriter format?
|
||||
:animate: fade-in-slide-down
|
||||
|
||||
There is a tool available that lets you convert a `yWriter <http://spacejock.com/yWriter7.html>`_
|
||||
|
||||
@@ -39,7 +39,8 @@ your notes about your characters, your world building, and so on.
|
||||
You can at any point split the individual documents by their headings up into multiple documents,
|
||||
or merge multiple documents into a single document. This makes it easier to use variations of the
|
||||
Snowflake_ method for writing. You can start by writing larger structure-focused documents, like
|
||||
for instance one document per act, and later effortlessly split these up into chapters or scenes.
|
||||
for instance one document per act, and later effortlessly split these up into individual chapter or
|
||||
scene documents.
|
||||
|
||||
|
||||
.. _a_intro_features:
|
||||
@@ -53,7 +54,7 @@ Below are some key features of novelWriter.
|
||||
The aim of the user interface is to let you focus on writing instead of spending time formatting
|
||||
text. Formatting is therefore limited to a small set of formatting tags for simple things like
|
||||
text emphasis and paragraph alignment. Additional shortcodes are available for special
|
||||
formatting cases.
|
||||
formatting cases when you need them.
|
||||
|
||||
When you really want to focus on just writing, you can switch the editor into **Focus Mode**
|
||||
where only the text editor panel itself is visible, and the project structure view is hidden
|
||||
@@ -78,19 +79,19 @@ Below are some key features of novelWriter.
|
||||
Your main story text lives in the "Novel" root folder. You can have multiple such folders in a
|
||||
project, and rename them to whatever you want. This allows you to keep a series of individual
|
||||
novels with the same characters and world building in the same project, and create manuscripts
|
||||
for them individually.
|
||||
for them separately.
|
||||
|
||||
**Keep track of your story elements**
|
||||
All notes in your project can be assigned a :term:`tag` that you can then :term:`reference` from
|
||||
any other document or note. In fact, you can add a new tag under each heading of a note if you
|
||||
need to be able to reference specific sections of it, or you want to keep several topics in the
|
||||
same note.
|
||||
All documents in your project can be assigned a :term:`tag` that you can then :term:`reference`
|
||||
from any other document or note. In fact, you can add a new tag under each heading of a note if
|
||||
you need to be able to reference specific sections of it, or you want to keep several topics or
|
||||
scenes in the same document.
|
||||
|
||||
**Get an overview of your story**
|
||||
It is not the documents themselves that define the chapters and scenes of your story, but the
|
||||
headings that separate them. In the **Outline View** on the main window you can see an outline
|
||||
of all the chapter and scene headings of each novel root folder in your project. If they have
|
||||
any references in them, like which character is in what chapter and scene, these are listed in
|
||||
headings within them. In the **Outline View** on the main window you can see an outline of all
|
||||
the chapter and scene headings of each novel root folder in your project. If they have any
|
||||
references in them, like which character is in what chapter and scene, these are listed in
|
||||
additional columns.
|
||||
|
||||
You can also add a synopsis to each chapter or scene, which can be listed here as well. You have
|
||||
@@ -107,9 +108,9 @@ Below are some key features of novelWriter.
|
||||
Whether you want to assemble a manuscript, or export all your notes, or generate an outline of
|
||||
your chapters and scenes with a synopsis included, you can use the **Build Manuscript** tool to
|
||||
do so. The tool lets you select what information you want to include in the generated document,
|
||||
and how it is formatted. You can send the result to a printer or PDF, or generate an Open
|
||||
Document file that can be opened by most office type word processors. You can also generate the
|
||||
result as HTML, or Markdown, both suitable for further conversion to other formats.
|
||||
and how it is formatted. You can send the result to a printer, or generate an Open Document or
|
||||
Word Document file that can be opened by most office type word processors. You can also generate
|
||||
the result as HTML, or Markdown, both suitable for further conversion to other formats.
|
||||
|
||||
|
||||
.. _a_intro_screenshots:
|
||||
|
||||
@@ -5,10 +5,10 @@ What to Read First
|
||||
******************
|
||||
|
||||
The documentation of novelWriter is quite extensive. There are a lot of features to get used to,
|
||||
but you don't need all of them to get started.
|
||||
but you don't need to know all of them to get started.
|
||||
|
||||
The chapters below labelled "Essential Information" are the ones you need to know to use the
|
||||
application correctly. By "correctly", it is meant: in a way so novelWriter understands the basic
|
||||
application correctly. By "correctly" it is meant: in a way so novelWriter understands the basic
|
||||
structure of your text. It collects a lot of information from your text and uses it to display the
|
||||
structure of it in various ways to help you get an overview of your writing.
|
||||
|
||||
@@ -23,7 +23,7 @@ Using novelWriter
|
||||
=================
|
||||
|
||||
In order to use novelWriter effectively, you need to know the basics of how it works. The following
|
||||
chapters will explain the main principles. It starts with the basics, and gets more detailed as you
|
||||
chapters will explain the main principles. They start with the basics, and get more detailed as you
|
||||
read on.
|
||||
|
||||
:ref:`a_breakdown` – Essential Information
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Setup and Installation
|
||||
**********************
|
||||
|
||||
.. _Enchant: https://abiword.github.io/enchant/
|
||||
.. _Enchant: http://rrthomas.github.io/enchant/
|
||||
.. _GitHub: https://github.com/vkbo/novelWriter
|
||||
.. _Downloads page: https://download.novelwriter.io
|
||||
.. _PPA: https://launchpad.net/~vkbo/+archive/ubuntu/novelwriter
|
||||
@@ -15,7 +15,7 @@ Setup and Installation
|
||||
.. _AppImage: https://appimage.org/
|
||||
|
||||
Ready-made packages and installers for novelWriter are available for all major platforms, including
|
||||
Linux, Windows and MacOS, from the `Downloads page`_. See below for install additional instructions
|
||||
Linux, Windows and MacOS, from the `Downloads page`_. See below for additional install instructions
|
||||
for each platform.
|
||||
|
||||
You can also install novelWriter from the Python Package Index (PyPi_). See :ref:`a_started_pip`.
|
||||
@@ -40,12 +40,6 @@ If you have any issues, try uninstalling the previous version and making a fresh
|
||||
already had a version installed via a different method, you should uninstall that first as having
|
||||
multiple installations has been known to cause problems.
|
||||
|
||||
.. note::
|
||||
The novelWriter installer is not signed because Microsoft doesn't currently provide a way for
|
||||
non-profit open source projects to properly sign their installers. The novelWriter project
|
||||
doesn't have the funding to pay for commercial software signing certificates. You will therefore
|
||||
see an additional warning about this when you download and run the installer.
|
||||
|
||||
|
||||
.. _a_started_linux:
|
||||
|
||||
@@ -93,10 +87,6 @@ Then run the update and install commands as for Ubuntu:
|
||||
sudo apt update
|
||||
sudo apt install novelwriter
|
||||
|
||||
.. note::
|
||||
You may need to use the Ubuntu 20.04 (focal) packages for Debian 11 or earlier. The newer Ubuntu
|
||||
packages use a different compression algorithm that may not be supported.
|
||||
|
||||
.. tip::
|
||||
If you get an error message like ``gpg: failed to create temporary file`` when importing the key
|
||||
from the Ubuntu keyserver, try creating the folder it fails on, and import the key again:
|
||||
@@ -142,7 +132,7 @@ done by holding down the option key on your keyboard and clicking on the applica
|
||||
.. note::
|
||||
The novelWriter DMG is not signed because Apple doesn't currently provide a way for non-profit
|
||||
open source projects to properly sign their installers. The novelWriter project doesn't have the
|
||||
funding to pay for commercial software signing certificates.
|
||||
funding to pay for a commercial software signing certificate.
|
||||
|
||||
|
||||
.. _a_started_pip:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Customisations
|
||||
**************
|
||||
|
||||
.. _Enchant: https://abiword.github.io/enchant
|
||||
.. _Enchant: https://rrthomas.github.io/enchant/
|
||||
.. _Free Desktop: https://cgit.freedesktop.org/libreoffice/dictionaries/tree/
|
||||
|
||||
There are a few ways you can customise novelWriter yourself. Currently, you can add new GUI themes,
|
||||
|
||||
@@ -11,6 +11,7 @@ from the text in your project. All of this is handled by the **Manuscript Build*
|
||||
activate it from the sidebar, the **Tools** menu, or by pressing :kbd:`F5`.
|
||||
|
||||
.. versionadded:: 2.1
|
||||
|
||||
This tool is new for version 2.1. A simpler tool was used for earlier versions. The simpler tool
|
||||
only allows you to define a single set of options for the build, but otherwise had much the same
|
||||
functionality.
|
||||
@@ -58,6 +59,7 @@ Each build definition can be edited by opening it in the **Manuscript Build Sett
|
||||
either by double-clicking or by selecting it and pressing the edit button in the toolbar.
|
||||
|
||||
.. tip::
|
||||
|
||||
You can keep the **Manuscript Build Settings** dialog open while testing the different options,
|
||||
and just hit the :guilabel:`Apply` button. You can test the result of your settings by pressing
|
||||
the :guilabel:`Preview` button in the main **Manuscript Build** window. When you're happy with
|
||||
@@ -177,21 +179,34 @@ Open Document Format
|
||||
flat version of the document format as a single XML file. Most rich text editors support the
|
||||
former, and only a few the latter.
|
||||
|
||||
Microsoft Word Document Format
|
||||
The Microsoft Word Document format writes a single ``.docx`` file. It uses a fairly basic format
|
||||
that should be compatible with most rich text editors.
|
||||
|
||||
Portable Document Format (PDF)
|
||||
The PDF is generated from a copy of the preview document, and should have the same formatting
|
||||
capabilities as the preview. It's identical to what is produced if you select the print option
|
||||
and print to PDF.
|
||||
|
||||
novelWriter HTML
|
||||
The HTML format writes a single ``.htm`` file with minimal style formatting. The HTML document
|
||||
is suitable for further processing by document conversion tools like Pandoc_, for importing in
|
||||
word processors, or for printing from browser.
|
||||
|
||||
Standard/Extended Markdown
|
||||
The Markdown format comes in both Standard and Extended flavour. The *only* difference in terms
|
||||
of novelWriter functionality is the support for strike through text, which is not supported by
|
||||
the Standard flavour.
|
||||
|
||||
novelWriter Markup
|
||||
This is simply a concatenation of the project documents selected by the filters into a ``.txt``
|
||||
file. The documents are stacked together in the order they appear in the project tree, with
|
||||
comments, tags, etc. included if they are selected. This is a useful format for exporting the
|
||||
project for later import back into novelWriter.
|
||||
|
||||
Standard/Extended Markdown
|
||||
The Markdown format comes in both Standard and Extended flavour. The *only* difference in terms
|
||||
of novelWriter functionality is the support for strike through text, which is not supported by
|
||||
the Standard flavour.
|
||||
.. versionadded:: 2.6
|
||||
|
||||
Microsoft Word and PDF output options were added.
|
||||
|
||||
|
||||
Additional Formats
|
||||
@@ -211,13 +226,14 @@ file can be imported directly into a Python dict object or a PHP array, to menti
|
||||
|
||||
.. _a_manuscript_print:
|
||||
|
||||
Print and PDF
|
||||
=============
|
||||
Printing
|
||||
========
|
||||
|
||||
The :guilabel:`Print` button allows you to print the content in the preview window. You can either
|
||||
print to one of your system's printers, or select PDF as your output format from the printer icon
|
||||
on the print dialog.
|
||||
|
||||
.. note::
|
||||
|
||||
The paper format should in all cases default to whatever your system default is. If you want to
|
||||
change it, you have to select it from the **Print Preview** dialog.
|
||||
|
||||
@@ -115,16 +115,17 @@ Root Folder Types
|
||||
options. Tags in this folder type can be references using the ``@custom`` keyword.
|
||||
|
||||
The root folders are closely tied to the tags and reference system. Each folder type corresponds to
|
||||
the categories of tags that can be used to reference them. For more information about the tags
|
||||
listed, see :ref:`a_references_references`.
|
||||
categories of tags that can be used to reference them. For more information about the tags listed,
|
||||
see :ref:`a_references_references`.
|
||||
|
||||
There is also a ``@mention`` keyword that can be used to reference any tag.
|
||||
See :ref:`a_references_references` for more details.
|
||||
|
||||
.. note::
|
||||
|
||||
You can rename root folders to whatever you want. However, this doesn't change the reference
|
||||
keyword or what they do.
|
||||
|
||||
.. versionadded:: 2.0
|
||||
As of version 2.0, you can make multiple root folders of each kind to split up your project.
|
||||
|
||||
|
||||
.. _a_proj_roots_del:
|
||||
|
||||
@@ -167,11 +168,10 @@ The folders are there purely as a way for you to organise the documents in meani
|
||||
to be able to collapse and hide them in the project tree when you're not working on those
|
||||
documents.
|
||||
|
||||
.. versionadded:: 2.0
|
||||
As of version 2.0 it is possible to add child documents to other documents. This is particularly
|
||||
useful when you create chapters and scenes. If you add separate scene documents, you should also
|
||||
add separate chapter documents, even if they only contain a chapter heading. You can then add
|
||||
scene documents as child items to the chapters.
|
||||
In addition to using folders. it is also possible to add child documents to other documents. This
|
||||
is particularly useful when you create chapters and scenes. If you add separate scene documents,
|
||||
you should also add separate chapter documents, even if they only contain a chapter heading. You
|
||||
can then add scene documents as child items to the chapters.
|
||||
|
||||
|
||||
.. _a_proj_roots_orphaned:
|
||||
@@ -182,7 +182,7 @@ Recovered Documents
|
||||
If novelWriter crashes or otherwise exits without saving the project state, or if you're using a
|
||||
file synchronisation tool that runs out of sync, there may be files in the project storage folder
|
||||
that aren't tracked in the core project file. These files, when discovered, are recovered and added
|
||||
back into the project.
|
||||
back into the project when a project is opened.
|
||||
|
||||
The discovered files are scanned for metadata that give clues as to where the document may
|
||||
previously have been located in the project. The project loading routine will try to put them back
|
||||
@@ -202,19 +202,21 @@ be named after its internal key, which is a string of characters and numbers.
|
||||
Project Lockfile
|
||||
----------------
|
||||
|
||||
To prevent lost documents caused by file conflicts when novelWriter projects are synchronised via
|
||||
file synchronisation tools, a project lockfile is written to the project storage folder when a
|
||||
project is open. If you try to open a project which already has such a file present, you will be
|
||||
presented with a warning, and some information about where else novelWriter thinks the project is
|
||||
also open. You will be given the option to ignore this warning, and continue opening the project at
|
||||
your own risk.
|
||||
To prevent data loss caused by file conflicts when novelWriter projects are synchronised via file
|
||||
synchronisation tools, a project lockfile is written to the project storage folder when a project
|
||||
is open. If you try to open a project which already has such a file present, you will be presented
|
||||
with a warning, and some information about where else novelWriter thinks the project is also open.
|
||||
You will be given the option to ignore this warning, and continue opening the project at your own
|
||||
risk.
|
||||
|
||||
.. note::
|
||||
|
||||
If, for some reason, novelWriter or your computer crashes, the lock file may remain even if
|
||||
there are no other instances keeping the project open. In such a case it is safe to ignore the
|
||||
lock file warning when re-opening the project.
|
||||
|
||||
.. warning::
|
||||
|
||||
If you choose to ignore the warning and continue opening the project, and multiple instances of
|
||||
the project are in fact open, you are likely to cause inconsistencies and create diverging
|
||||
project files, potentially resulting in loss of data and orphaned files. You are not likely to
|
||||
@@ -315,9 +317,14 @@ use these to indicate at what stage of completion each novel document is, or how
|
||||
content of a note is to the story. You don't have to use them this way, that's just what they were
|
||||
intended for, but you can make them whatever you want.
|
||||
|
||||
Both status and importance labels can be exported and imported so you can share them between
|
||||
projects, or define a standard set for all your writing projects. When you import labels to a
|
||||
project, they are always added as *new* labels.
|
||||
|
||||
See also :ref:`a_ui_tree_status`.
|
||||
|
||||
.. note::
|
||||
|
||||
The status or importance level currently in use by one or more documents cannot be deleted, but
|
||||
they can be edited.
|
||||
|
||||
@@ -381,7 +388,22 @@ also export the filtered data to a JSON file or to a CSV file that can be opened
|
||||
application like for instance Libre Office Calc or Excel.
|
||||
|
||||
.. versionadded:: 1.2
|
||||
|
||||
As of version 1.2, the log file also stores how much of the session time was spent idle. The
|
||||
definition of idle here is that the novelWriter main window loses focus, or the user hasn't made
|
||||
any changes to the currently open document in five minutes. The number of minutes can be altered
|
||||
in **Preferences**.
|
||||
|
||||
|
||||
Session Timer
|
||||
-------------
|
||||
|
||||
A session timer is by default visible in the status bar. The icon will show you a clock icon when
|
||||
you are active, and a pause icon when you are considered "idle" per the criteria mentioned above.
|
||||
|
||||
If you do not wish to see the timer, you can click on it once to hide it. The icon will still be
|
||||
visible. Click the icon once more to display the timer again.
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
As of version 2.6, clicking the timer text or icon in the status bar will toggle its visibility.
|
||||
|
||||
@@ -15,8 +15,9 @@ applications. It is therefore not always obvious to new users how this is suppos
|
||||
this chapter hopes to explain in more detail how to use this tags and references system.
|
||||
|
||||
.. tip::
|
||||
|
||||
If you find the Tags and Reference system difficult to follow just from reading this chapter,
|
||||
you can create a new project in the **Welcome** dialog's New project form and select "Create an
|
||||
you can create a new project in the **Welcome** dialog's New project form and select "Create an
|
||||
example project" from the "Pre-fill project" option. The example project contains several
|
||||
examples of tags and references.
|
||||
|
||||
@@ -39,7 +40,8 @@ documents up using the document split feature. See :ref:`a_ui_tree_split_merge`
|
||||
You can do the same with your notes. You can treat each heading as an independent element of your
|
||||
notes that can be referenced somewhere else. That way you can collect all your minor or background
|
||||
characters in a single note file, and still be able to reference them individually by separating
|
||||
them with headings and assigning each a tag.
|
||||
them with headings and assigning each a tag. Or, you can lay out your plot in a single document and
|
||||
have a tag for each plot stage under separate headings.
|
||||
|
||||
|
||||
.. _a_references_tags:
|
||||
@@ -82,13 +84,20 @@ to the manuscript, or an outline document. If you want to compile a single docum
|
||||
notes, you can do this from the **Manuscript Build** tool.
|
||||
|
||||
.. versionadded:: 2.2
|
||||
|
||||
Tags are no longer case sensitive. The tags are by default displayed with the capitalisation you
|
||||
use when defining the tag, but you don't have to use the same capitalisation when referencing
|
||||
it later.
|
||||
|
||||
.. versionadded:: 2.3
|
||||
|
||||
Tags can have an optional display name for manuscript builds.
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
You can now add tags also to Novel Documents. These can be used for cross-referencing between
|
||||
chapters and scenes, and also from notes if desired.
|
||||
|
||||
Example of a heading with a tag for a character of the story:
|
||||
|
||||
.. code-block:: md
|
||||
@@ -162,15 +171,27 @@ reference keywords allow multiple values.
|
||||
Custom references in the current section. The target must be a note tag in a **Custom** type
|
||||
root folder. The custom folder are for any other category of notes you may want to use.
|
||||
|
||||
``@mention``
|
||||
Anything mentioned, but not present in the current section. It is intended for those cases where
|
||||
you reveal details about a character or place in a scene without it being otherwise a part of
|
||||
it. This can be useful when checking for consistency later. Any tag in any root note folder can
|
||||
be listed under mentions.
|
||||
|
||||
``@story``
|
||||
This is used when referencing a Novel Document, like a scene or chapter, from somewhere else in
|
||||
your project.
|
||||
|
||||
The syntax highlighter will alert the user that the tags and references are used correctly, and
|
||||
that the tags referenced exist.
|
||||
|
||||
.. note::
|
||||
|
||||
The highlighter may be mistaken if the index of defined tags is out of date. If so, press
|
||||
:kbd:`F9` to regenerate it, or select **Rebuild Index** from the **Tools** menu. In general, the
|
||||
index for a document is regenerated when it is saved, so this shouldn't normally be necessary.
|
||||
|
||||
.. tip::
|
||||
|
||||
If you add a reference in the editor to a tag that doesn't yet exist, you can right-click it and
|
||||
select **Create Note for Tag**. This will generate a new project note automatically with the new
|
||||
tag defined. In order for this to be possible, a root folder for that category of references
|
||||
@@ -186,6 +207,7 @@ meaningful if you want to be able to click-navigate between them, or of course i
|
||||
highlight that two notes are related.
|
||||
|
||||
.. tip::
|
||||
|
||||
If you cross-reference between notes and export your project as an HTML document using the
|
||||
**Manuscript Build** tool, the cross-references become clickable links in the exported HTML
|
||||
document as well.
|
||||
|
||||
@@ -11,8 +11,8 @@ Active novel documents can only live in a **Novel** type root folder. You can al
|
||||
**Archive** and **Trash** of course, where they become inactive.
|
||||
|
||||
The project tree can distinguish between the different heading levels of the novel documents using
|
||||
coloured icons, and optionally add emphasis on the label, set in **Preferences** for easier
|
||||
identification.
|
||||
coloured icons, and optionally add emphasis on the label. Emphasis can be enabled in
|
||||
**Preferences**.
|
||||
|
||||
|
||||
.. _a_struct_heads:
|
||||
@@ -27,6 +27,7 @@ Four levels of headings are supported, signified by the number of hashes (``#``)
|
||||
title. See also the :ref:`a_fmt` section for more details about the markup syntax.
|
||||
|
||||
.. note::
|
||||
|
||||
The heading levels are not only important when generating the manuscript, they are also used by
|
||||
the indexer when building the outline tree in the **Outline View** as well as in the **Novel
|
||||
Tree**. Each heading also starts a new region where new Tags and References can be defined. See
|
||||
@@ -60,16 +61,25 @@ The syntax for the four basic heading types, and the three special types, is lis
|
||||
mid-scene, like if you change the point-of-view character. You are free to use sections as you
|
||||
wish, and you can filter them out of the final manuscript.
|
||||
|
||||
Page breaks can be automatically added before partition, chapter and scene headings from the
|
||||
**Manuscript Build** tool when you build your project to a format that supports page breaks. If you
|
||||
want page breaks in other places, you have to specify them manually. See :ref:`a_fmt_break`.
|
||||
Page breaks can be automatically added before titles, partition, chapter and scene headings from
|
||||
the **Manuscript Build** tool when you build your project to a format that supports page breaks. If
|
||||
you want page breaks in other places, you have to specify them manually. See :ref:`a_fmt_break`.
|
||||
|
||||
.. tip::
|
||||
|
||||
There are multiple options of how to process novel headings when building the manuscript. For
|
||||
instance, chapter numbers can be applied automatically, and so can scene numbers if you want
|
||||
them in a draft manuscript. You can also insert point-of-view character names in chapter titles.
|
||||
See the :ref:`a_manuscript` page for more details.
|
||||
|
||||
.. note::
|
||||
|
||||
As of 2.6, the heading levels internally in novelWriter do not map directly to heading levels in
|
||||
manuscript documents. In manuscript documents, chapters are considered the top level heading,
|
||||
and partitions become plain text paragraphs with a larger font.
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
|
||||
.. _a_struct_heads_title:
|
||||
|
||||
@@ -77,15 +87,28 @@ Novel Title and Front Matter
|
||||
----------------------------
|
||||
|
||||
It is recommended that you add a document at the very top of each **Novel** root folder with the
|
||||
novel title as the first line. You should modify the level 1 heading format code with an ``!`` in
|
||||
order to render it as a document title that is excluded from any automatic Table of Content in a
|
||||
manuscript build document, like so:
|
||||
novel title in it. You should modify the level 1 heading format code with an ``!`` in order to
|
||||
render it as a document title that is excluded from any automatic Table of Content in a manuscript
|
||||
build document.
|
||||
|
||||
You can also add the author name and address above this if this is required by the manuscript
|
||||
format you use, and additional space added before the title.
|
||||
|
||||
This is the title page novelWriter generates automatically for a new project as of version 2.6:
|
||||
|
||||
.. code-block:: md
|
||||
|
||||
Jane Doe[br]
|
||||
Address 1[br]
|
||||
Address 2 <<
|
||||
|
||||
[vspace:5]
|
||||
|
||||
#! My Novel
|
||||
|
||||
>> _by Jane Doe_ <<
|
||||
>> **By Jane Doe** <<
|
||||
|
||||
>> Word Count: [field:textWords] <<
|
||||
|
||||
The title is by default centred on the page. You can add more text to the page as you wish, like
|
||||
for instance the author's name and details.
|
||||
|
||||
@@ -7,6 +7,8 @@ How it Works
|
||||
.. _Fusion: https://doc.qt.io/qt-6/gallery.html
|
||||
.. _Pandoc: https://pandoc.org/
|
||||
.. _Open Document: https://en.wikipedia.org/wiki/OpenDocument
|
||||
.. _Office Open XML: https://en.wikipedia.org/wiki/Office_Open_XML
|
||||
.. _version control: https://en.wikipedia.org/wiki/Version_control
|
||||
|
||||
The main features of novelWriter are listed in the :ref:`a_intro` chapter. In this chapter, we go
|
||||
into some more details on how they are implemented. This is intended as an overview. Later on in
|
||||
@@ -42,9 +44,10 @@ views you can switch between, a quick link to the **Build Manuscript** tool, and
|
||||
project-related tools and quick access to settings at the bottom.
|
||||
|
||||
.. versionadded:: 2.2
|
||||
|
||||
A number of new formatting options were added in 2.2 to allow for some special formatting cases.
|
||||
At the same time, a small formatting toolbar was added to the editor. It is hidden by default,
|
||||
but can be opened by pressing the button in the top right corner of the editor header.
|
||||
but can be opened by pressing the button in the top--left corner of the editor header.
|
||||
|
||||
|
||||
Project Tree and Editor View
|
||||
@@ -54,9 +57,9 @@ Project Tree and Editor View
|
||||
|
||||
A screenshot of the Project Tree and Editor View.
|
||||
|
||||
When the application is in **Project Tree View** mode, the main work area of the main window is
|
||||
split in two, or optionally three, panels. The left-most panel contains the project tree and all
|
||||
the documents in your project. The second panel is the document editor.
|
||||
When the application is in **Project Tree View** mode, the work area of the main window is split in
|
||||
two, or optionally three, panels. The left-most panel contains the project tree and all the
|
||||
documents in your project. The second panel is the document editor.
|
||||
|
||||
An optional third panel on the right side contains a document viewer which can view any document in
|
||||
your project independently of what is open in the document editor. This panel is not intended as a
|
||||
@@ -68,6 +71,14 @@ The editor also has a **Focus Mode** you can toggle either from the menu, from t
|
||||
editor's header, or by pressing :kbd:`F8`. When **Focus Mode** is enabled, all the user interface
|
||||
elements other than the document editor itself are hidden away.
|
||||
|
||||
The project tree will highlight with a different background colour the document that is currently
|
||||
open in the editor.
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
You can now drag and drop documents from the project tree onto the editor or viewer panels to
|
||||
open them.
|
||||
|
||||
|
||||
Novel View and Editor View
|
||||
--------------------------
|
||||
@@ -110,21 +121,20 @@ Novel Outline View
|
||||
|
||||
A screenshot of the Novel Outline View.
|
||||
|
||||
When the application is in **Novel Outline View** mode, the tree, editor and viewer will be
|
||||
replaced by a large table that shows the entire novel structure with all the tags and references
|
||||
listed. Pretty much all collected meta data is available here in different columns.
|
||||
When the application is in **Novel Outline View** mode, the tree, editor and viewer are replaced by
|
||||
a large table that shows the entire novel structure with all the tags and references listed. Pretty
|
||||
much all collected meta data is available here in different columns.
|
||||
|
||||
You can select which novel root folder to display from the dropdown box, and you can select which
|
||||
columns to show or hide from the menu button. You can also rearrange the columns by drag and drop.
|
||||
The app will remember your column order and size between sessions, and for each individual project.
|
||||
The app remembers your column order and sizes between sessions, and for each individual project.
|
||||
|
||||
|
||||
Colour Themes
|
||||
-------------
|
||||
|
||||
By default, novelWriter will use the colour theme provided by the Qt library, which is determined
|
||||
by the Fusion_ style setting. You can also choose between a standard dark and light theme that have
|
||||
neutral colours from **Preferences**.
|
||||
By default, novelWriter uses a light colour theme. You can also choose between a standard dark
|
||||
theme that have neutral colours, or a series of other included themes, from **Preferences**.
|
||||
|
||||
If you wish, you *can* create your own colour themes, and even have them added to the application.
|
||||
See :ref:`a_custom_theme` for more details.
|
||||
@@ -134,6 +144,7 @@ separate colour selectable from the "Document colour theme" setting in **Prefere
|
||||
separated because there are a lot more options to choose from for the editor and viewer.
|
||||
|
||||
.. note::
|
||||
|
||||
If you switch between light and dark mode on the GUI, you should also switch editor theme to
|
||||
match, otherwise icons may be hard to see in the editor and viewer.
|
||||
|
||||
@@ -155,11 +166,11 @@ If the project or novel view does not have focus, pressing :kbd:`Ctrl+T` switche
|
||||
whichever of the two is visible. If one of them already has focus, the key press will switch
|
||||
between them instead.
|
||||
|
||||
Likewise, pressing :kbd:`Ctrl+E` with switch focus to the document editor or viewer, or if any of
|
||||
Likewise, pressing :kbd:`Ctrl+E` will switch focus to the document editor or viewer, or if any of
|
||||
them already have focus, it will switch focus between them,
|
||||
|
||||
These two shortcuts makes it possible to jump between all these GUI elements without having to
|
||||
reach for the mouse or touchpad.
|
||||
These two shortcuts make it possible to jump between all these GUI elements without having to reach
|
||||
for the mouse or touchpad.
|
||||
|
||||
|
||||
.. _a_breakdown_project:
|
||||
@@ -167,7 +178,7 @@ reach for the mouse or touchpad.
|
||||
Project Layout
|
||||
==============
|
||||
|
||||
This is a brief introduction to how you structure your writing projects. All of this will be
|
||||
This is a brief introduction to how you should structure your writing projects. All of this will be
|
||||
covered in more detail later.
|
||||
|
||||
The main point of novelWriter is that you are free to organise your project documents as you wish
|
||||
@@ -193,12 +204,19 @@ This heading level structure is only taken into account for :term:`novel documen
|
||||
:term:`project notes`, the heading levels have no structural meaning, and you are free to use them
|
||||
however you want. See :ref:`a_struct` and :ref:`a_references` for more details.
|
||||
|
||||
.. versionadded:: 2.0
|
||||
.. tip::
|
||||
|
||||
You can add documents as child items of other documents. This is often more useful than adding
|
||||
folders, since you anyway may want to have the chapter heading in a separate document from your
|
||||
individual scene documents so that you can rearrange scene documents freely without affecting
|
||||
chapter placement.
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
The heading levels for partitions, chapters and scenes only apply within novelWriter. When you
|
||||
generate your manuscript, chapters are considered as the topmost heading level, with scenes
|
||||
below it. Partitions are inserted as text elements in most formats.
|
||||
|
||||
|
||||
.. _a_breakdown_export:
|
||||
|
||||
@@ -206,22 +224,24 @@ Building a Manuscript
|
||||
=====================
|
||||
|
||||
The project can at any time be assembled into a range of different formats through the
|
||||
**Build Manuscript** tool. Natively, novelWriter supports `Open Document`_, HTML5, and
|
||||
various flavours of Markdown.
|
||||
**Build Manuscript** tool. Natively, novelWriter supports `Open Document`_, Microsoft Word Document
|
||||
(known as `Office Open XML`_), HTML5, and various flavours of Markdown. You can also generate a PDF
|
||||
document.
|
||||
|
||||
The HTML5 format is suitable for conversion by a number of other tools like Pandoc_, or for
|
||||
importing into word processors if the Open Document format isn't suitable. The Open Document format
|
||||
is supported by most office type applications. In addition, printing is also possible. Print to PDF
|
||||
is available from the print dialog.
|
||||
importing into word processors if the Open Document or Word Document format isn't suitable. The
|
||||
Open Document format is supported by most office type applications, so is the Word Document format.
|
||||
In addition, printing is also possible.
|
||||
|
||||
For advanced processing, you can export the content of the project to a JSON file. This is useful
|
||||
if you want to write your own custom processing script in for instance Python, as the entire novel
|
||||
can be read into a Python dictionary with a couple of lines of code. The JSON file can be populated
|
||||
with either HTML formatted text, or with the raw text as typed into the novel documents.
|
||||
with either HTML formatted text, or with the raw text as typed it into the novel documents.
|
||||
|
||||
See :ref:`a_manuscript` for more details.
|
||||
|
||||
.. versionadded:: 2.1
|
||||
|
||||
You can now define multiple build definitions in the **Build Manuscript** tool. This allows you
|
||||
to define specific settings for various types of draft documents, outline documents, and
|
||||
manuscript formats. See :ref:`a_manuscript` for more details.
|
||||
@@ -234,8 +254,8 @@ Project Storage
|
||||
|
||||
The files of a novelWriter project are stored in a dedicated project folder. The project structure
|
||||
is kept in a file at the root of this folder called ``nwProject.nwx``. All the document files and
|
||||
associated meta data is stored in other folders below the project folder. For more technical
|
||||
details about what all the files mean and how they're organised, see the :ref:`a_storage` section.
|
||||
associated meta data are stored in other folders below the project folder. For a more technical
|
||||
description of what all the files mean and how they're organised, see the :ref:`a_storage` section.
|
||||
|
||||
This way of storing data was chosen for several reasons.
|
||||
|
||||
@@ -247,17 +267,19 @@ crashes.
|
||||
Secondly, having multiple small files means it is very easy to synchronise them between computers
|
||||
with standard file synchronisation tools.
|
||||
|
||||
Thirdly, if you use version control software to track the changes to your project, the file formats
|
||||
used for the files are well suited. All the JSON documents have line breaks and indents as well,
|
||||
which makes it easier to track them with version control software.
|
||||
Thirdly, if you use `version control`_ software to track the changes to your project, the file
|
||||
formats used for the files are well suited. All the JSON documents have line breaks and indents as
|
||||
well, which makes it easier to track them with version control software.
|
||||
|
||||
.. note::
|
||||
|
||||
Since novelWriter has to keep track of a bunch of files and folders when a project is open, it
|
||||
may not run well on some virtual file systems. A file or folder must be accessible with exactly
|
||||
the path it was saved or created with. An example where this is not the case is the way Google
|
||||
Drive is mapped on Linux Gnome desktops using gvfs/gio.
|
||||
|
||||
.. caution::
|
||||
|
||||
You should not add additional files to the project folder yourself. Nor should you, as a rule,
|
||||
manually edit files within it. If you really must manually edit the text files, e.g. with some
|
||||
automated task you want to perform, you need to rebuild the :term:`Project Index` when you open
|
||||
|
||||
@@ -14,6 +14,10 @@ In addition to formatting codes, novelWriter allows for comments, a synopsis tag
|
||||
keyword and value sets used for :term:`tags<tag>` and :term:`references<reference>`. There are also
|
||||
some codes that apply to whole paragraphs. See :ref:`a_fmt_text` for more details.
|
||||
|
||||
URLs should also be highlighted and become clickable. However, only URLs starting with "http" or
|
||||
"https" are recognised. In the editor, you must hold down the :kbd:`Ctrl` key when clicking a URL
|
||||
to follow it.
|
||||
|
||||
|
||||
.. _a_fmt_hlight:
|
||||
|
||||
@@ -76,14 +80,13 @@ For headings level one through three, adding a ``!`` modifies the meaning of the
|
||||
|
||||
``#! Title Text``
|
||||
This tells the build tool that the level one heading is intended to be used for the novel or
|
||||
notes folder's main title, like for instance on the front page. When building the manuscript,
|
||||
this will use a different styling and will exclude the title from, for instance, a Table of
|
||||
Contents in Libre Office.
|
||||
notes folder's main title, like for instance the novel title on the cover page. When building
|
||||
the manuscript, this will use a different styling.
|
||||
|
||||
``##! Title Text``
|
||||
This tells the build tool to not assign a chapter number to this chapter title if automatic
|
||||
chapter numbers are being used. Such titles are useful for a prologue for instance. See
|
||||
:ref:`a_struct_heads_unnum` for more details.
|
||||
chapter numbers are being used. Such titles are useful for prologues and epilogues for instance.
|
||||
See :ref:`a_struct_heads_unnum` for more details.
|
||||
|
||||
``###! Title Text``
|
||||
This is an alternative scene heading that can be formatted differently in the **Manuscript
|
||||
@@ -92,6 +95,7 @@ For headings level one through three, adding a ``!`` modifies the meaning of the
|
||||
details.
|
||||
|
||||
.. note::
|
||||
|
||||
The space after the ``#`` or ``!`` character is mandatory. The syntax highlighter will change
|
||||
colour and font size when the heading is correctly formatted.
|
||||
|
||||
@@ -118,10 +122,18 @@ Non-breaking spaces are highlighted by the syntax highlighter with an alternate
|
||||
background, depending on the selected theme.
|
||||
|
||||
.. tip::
|
||||
|
||||
Non-breaking spaces are for instance the correct type of space to separate a number from its
|
||||
unit. Generally, non-breaking spaces are used to prevent line wrapping algorithms from adding
|
||||
line breaks where they shouldn't.
|
||||
|
||||
.. note::
|
||||
|
||||
You should not manually apply text indentation in your paragraphs in the editor. These can be
|
||||
added automatically in the **Manuscript Build** tool if you want to have them in the manuscript.
|
||||
Each new paragraph should be separated from the previous content by an empty line and with no
|
||||
indentation.
|
||||
|
||||
|
||||
.. _a_fmt_emph:
|
||||
|
||||
@@ -160,6 +172,7 @@ In addition, the following rules apply:
|
||||
allow for formatting, and any formatting markup will be rendered as-is.
|
||||
|
||||
.. tip::
|
||||
|
||||
novelWriter supports standard escape syntax for the emphasis markup characters in case the
|
||||
editor misunderstands your intended usage of them. That is, ``\*``, ``\_`` and ``\~`` will
|
||||
generate a plain ``*``, ``_`` and ``~``, respectively, without interpreting them as part of the
|
||||
@@ -216,8 +229,8 @@ In addition to the above formatting features, novelWriter also allows for commen
|
||||
The text of a comment is always ignored by the word counter. The text can also be filtered out
|
||||
when building the manuscript or viewing the document.
|
||||
|
||||
The first word of a comment, followed by a colon, can be one of a small set of modifiers that
|
||||
indicates the comment is intended for a specific purpose. For instance, if the comment starts with
|
||||
The first word of a comment, followed by a colon, can be one of a set of modifiers that indicates
|
||||
the comment is intended for a specific purpose. For instance, if the comment starts with
|
||||
``Synopsis:``, the comment is treated in a special manner and will show up in the
|
||||
:ref:`a_ui_outline` in a dedicated column. The word ``synopsis`` is not case sensitive. If it is
|
||||
correctly formatted, the syntax highlighter will indicate this by altering the colour of the word.
|
||||
@@ -246,6 +259,7 @@ The different styles of comments are as follows:
|
||||
use them in your text.
|
||||
|
||||
.. note::
|
||||
|
||||
Only one comment can be flagged as a synopsis or short comment for each heading. If multiple
|
||||
comments are flagged as synopsis or short comments, the last one will be used and the rest
|
||||
ignored.
|
||||
@@ -365,6 +379,7 @@ Examples:
|
||||
"``> Left/right indented text <``", "The text has both margins increased."
|
||||
|
||||
.. note::
|
||||
|
||||
The text editor will not show the alignment and indentation live. But the viewer will show them
|
||||
when you open the document there. It will of course also be reflected in the document generated
|
||||
from the manuscript build tool as long as the format supports paragraph alignment.
|
||||
@@ -373,18 +388,15 @@ Examples:
|
||||
Alignment with Line Breaks
|
||||
--------------------------
|
||||
|
||||
If you have line breaks in the paragraph, like for instance when you are writing verses, the
|
||||
alignment markers must be applied to the first line. Markers on the other lines are ignored. The
|
||||
markers for the first line are used for all the other lines.
|
||||
|
||||
For the following text, all lines will be centred, not just the first:
|
||||
If you have line breaks in the paragraph, the markers for all the lines are combined and used for
|
||||
the entire paragraph. For the following text, all lines will be centred:
|
||||
|
||||
.. code-block:: md
|
||||
|
||||
>> I am the very model of a modern Major-General <<
|
||||
>> I am the very model of a modern Major-General
|
||||
I've information vegetable, animal, and mineral
|
||||
I know the kings of England, and I quote the fights historical
|
||||
From Marathon to Waterloo, in order categorical
|
||||
From Marathon to Waterloo, in order categorical <<
|
||||
|
||||
|
||||
Alignment with First Line Indent
|
||||
@@ -424,6 +436,7 @@ If you need to add a page break somewhere, put the text ``[new page]`` on a line
|
||||
the text you wish to start on a new page.
|
||||
|
||||
.. note::
|
||||
|
||||
The page break code is applied to the text that follows it. It adds a "page break before" mark
|
||||
to the text when exporting to HTML or Open Document. This means that a ``[new page]`` which has
|
||||
no text following it, it will not result in a page break.
|
||||
@@ -442,3 +455,16 @@ the text you wish to start on a new page.
|
||||
[new page]
|
||||
|
||||
This text will start on a new page if the build format has pages.
|
||||
|
||||
|
||||
.. _a_fmt_stats:
|
||||
|
||||
Inserting Word Counts in the Text
|
||||
=================================
|
||||
|
||||
The cover page of a manuscript normally has the word count stated on it. Any statistics value
|
||||
collected by novelWriter can be inserted into any document. You can generate the code for this from
|
||||
the **Insert** menu under **Word/Character Count**.
|
||||
|
||||
The value inserted is the actual count for your entire manuscript, so it is not populated until you
|
||||
run the **Manuscript Build** tool. Until then they will show up as "0" in the viewer panel.
|
||||
|
||||
@@ -63,6 +63,7 @@ currently selected item. This panel also includes the latest paragraph and chara
|
||||
addition to the word count.
|
||||
|
||||
.. tip::
|
||||
|
||||
If you want to set the label of a document to be the same as a header within it, you can
|
||||
right-click a header in the document when it is open in the editor and select
|
||||
:guilabel:`Set as Document Name` from the context menu.
|
||||
@@ -140,23 +141,19 @@ lives in. If it's in a **Novel** type folder, it uses the "Status" flag, otherwi
|
||||
Project Tree Drag & Drop
|
||||
------------------------
|
||||
|
||||
The project tree allows drag & drop to a certain extent to allow you to reorder your documents and
|
||||
folders. Moving a document in the project tree will affect the text's position when you assemble
|
||||
your manuscript in the **Manuscript Build** tool.
|
||||
The project tree allows drag & drop to allow you to reorder your documents and folders. Moving a
|
||||
document in the project tree will affect the text's position when you assemble your manuscript in
|
||||
the **Manuscript Build** tool.
|
||||
|
||||
.. versionadded:: 2.2
|
||||
|
||||
You can now select multiple items in the project tree by holding down the :kbd:`Ctrl` or
|
||||
:kbd:`Shift` key while selecting items.
|
||||
|
||||
You can drag and drop documents and regular folders, but not root folders. If you select multiple
|
||||
items, they can only be dragged and dropped if they are siblings. That is, they have the same
|
||||
parent item in the project. This limitation is due to the way drag and drop is implemented in the
|
||||
user interface framework novelWriter is built upon.
|
||||
|
||||
Documents and their folders can be rearranged freely within their root folders. If you move a
|
||||
**Novel Document** out of a **Novel** folder, it will be converted to a **Project Note**. Notes can
|
||||
be moved freely between all root folders, but keep in mind that if you move a note into a **Novel**
|
||||
type root folder, its "Importance" setting will be switched with a "Status" setting. See
|
||||
type root folder, its "Importance" setting will be replaced by a "Status" setting. See
|
||||
:ref:`a_ui_tree_status`. The old value will not be overwritten though, and should be restored if
|
||||
you move it back at some point.
|
||||
|
||||
@@ -164,6 +161,10 @@ Root folders in the project tree cannot be dragged and dropped at all. If you wa
|
||||
you can move them up or down with respect to each other from the arrow buttons at the top of the
|
||||
project tree, or by pressing :kbd:`Ctrl+Up` or :kbd:`Ctrl+Down` when they are selected.
|
||||
|
||||
.. tip::
|
||||
|
||||
You can drag and drop documents onto the editor or viewer panel to open them.
|
||||
|
||||
|
||||
.. _a_ui_tree_novel:
|
||||
|
||||
@@ -180,6 +181,7 @@ the view in the **Outline View**. It is convenient when you want to browse the s
|
||||
story itself rather than the document files.
|
||||
|
||||
.. note::
|
||||
|
||||
You cannot reorganise the entries in the novel view, or add any new documents, as that would
|
||||
imply restructuring the content of the document files themselves. Any such editing must be done
|
||||
in the project tree. However, you can add new headings to existing documents, or change
|
||||
@@ -208,6 +210,7 @@ You can select which novel folder to display from the dropdown menu. You can opt
|
||||
to show a combination of all novel folders.
|
||||
|
||||
.. note::
|
||||
|
||||
Since the internal structure of the novel does not depend directly on the folder and document
|
||||
structure of the project tree, this view will not necessarily look the same, depending on how
|
||||
you choose to organise your documents. See the :ref:`a_struct` page for more details.
|
||||
@@ -218,6 +221,7 @@ the menu button in the toolbar. The order of the columns can also be rearranged
|
||||
a different position. You column settings are saved between sessions on a per-project basis.
|
||||
|
||||
.. note::
|
||||
|
||||
The **Title** column cannot be disabled or moved.
|
||||
|
||||
The information viewed in the outline is based on the :term:`project index`. While novelWriter does
|
||||
|
||||
@@ -165,6 +165,7 @@ a key or key combination for the inserted content.
|
||||
":kbd:`Ctrl+K`, :kbd:`G`", "Insert a ``@tag`` keyword"
|
||||
":kbd:`Ctrl+K`, :kbd:`H`", "Insert a short description comment"
|
||||
":kbd:`Ctrl+K`, :kbd:`L`", "Insert a ``@location`` keyword"
|
||||
":kbd:`Ctrl+K`, :kbd:`M`", "Insert a ``@mention`` keyword"
|
||||
":kbd:`Ctrl+K`, :kbd:`O`", "Insert an ``@object`` keyword"
|
||||
":kbd:`Ctrl+K`, :kbd:`P`", "Insert a ``@plot`` keyword"
|
||||
":kbd:`Ctrl+K`, :kbd:`S`", "Insert a synopsis comment"
|
||||
|
||||
@@ -18,10 +18,10 @@ Editing a Document
|
||||
|
||||
A screenshot of the Document Editor panel.
|
||||
|
||||
To edit a document, double-click it in the project tree, or press the :kbd:`Return` key while
|
||||
having it selected. This will open the document in the document editor. The editor uses a
|
||||
Markdown-like syntax for some features, and a novelWriter-specific syntax for others. The syntax
|
||||
format is described in the :ref:`a_fmt` chapter.
|
||||
To edit a document, double-click it in the project tree, press the :kbd:`Return` key while having
|
||||
it selected, or drag and drop it onto the editor panel. This will open the document in the document
|
||||
editor. The editor uses a Markdown-like syntax for some features, and a novelWriter-specific syntax
|
||||
for others. The syntax format is described in the :ref:`a_fmt` chapter.
|
||||
|
||||
The editor has a maximise button, which toggles the **Focus Mode**, and a close button in the
|
||||
top--right corner. On the top--left side you will find a tools button that opens a toolbar with a
|
||||
@@ -33,6 +33,7 @@ header at the top of the edit or view panel. Optionally, the full project path t
|
||||
be shown. This can be set in **Preferences**.
|
||||
|
||||
.. tip::
|
||||
|
||||
Clicking on the document title bar will select the document in the project tree and thus reveal
|
||||
its location there, making it easier to find in a large project.
|
||||
|
||||
@@ -40,6 +41,24 @@ Any :term:`references<reference>` in the editor can be opened in the viewer by m
|
||||
the label and pressing :kbd:`Ctrl+Return`. You can also control-click them with your mouse.
|
||||
|
||||
|
||||
Spell Checking
|
||||
--------------
|
||||
|
||||
A third party library called Enchant is used for spell checking in the editor. The controls for
|
||||
spell checking can be found in the **Tools** menu. You can also set spell checking language in
|
||||
**Project Settings**.
|
||||
|
||||
This spell checking library comes with support for custom words that you can add by selecting
|
||||
"Add Word to Dictionary" from the context menu when a word is highlighted by the spell checker as
|
||||
misspelled. The custom words are managed on a per-project basis, and can the list of words can be
|
||||
edited from the **Project Word List** tool available from the **Tools** menu.
|
||||
|
||||
.. note::
|
||||
|
||||
Generally, spell checking dictionaries are collected from your operating system, but on Windows
|
||||
they are not. See :ref:`a_custom_dict` for how to add spell checking dictionaries on Windows.
|
||||
|
||||
|
||||
Editor Auto-Completer
|
||||
---------------------
|
||||
|
||||
@@ -61,14 +80,14 @@ Viewing a Document
|
||||
A screenshot of the Document Viewer panel.
|
||||
|
||||
Any document in the project tree can also be viewed in parallel in a right hand side document
|
||||
viewer. To view a document, press :kbd:`Ctrl+R`, or select **View Document** in the menu or context
|
||||
menu. If you have a middle mouse button, middle-clicking on the document will also open it in the
|
||||
viewer.
|
||||
viewer. To view a document, press :kbd:`Ctrl+R`, select **View Document** in the menu or context
|
||||
menu, or drag and drop the document onto the viewer panel. If you have a middle mouse button,
|
||||
middle-clicking on the document will also open it in the viewer.
|
||||
|
||||
The document viewed does not have to be the same document as the one currently being edited.
|
||||
However, If you *are* viewing the same document, pressing :kbd:`Ctrl+R` again will update the
|
||||
document with your latest changes. You can also press the reload button in the top--right corner of
|
||||
the viewer panel, next to the close button, to achieve the same thing.
|
||||
However, If you *are* viewing the same document, pressing :kbd:`Ctrl+R` from the editor will update
|
||||
the document with your latest changes. You can also press the reload button in the top--right
|
||||
corner of the viewer panel, next to the close button, to achieve the same thing.
|
||||
|
||||
In the viewer :term:`references<reference>` become clickable links. Clicking them will replace the
|
||||
content of the viewer with the content of the document the reference points to.
|
||||
@@ -77,7 +96,7 @@ The document viewer keeps a history of viewed documents, which you can navigate
|
||||
buttons in the top--left corner of the viewer. If your mouse has backward and forward navigation
|
||||
buttons, these can be used as well. They work just like the backward and forward features in a
|
||||
browser. The left-most button is a dropdown menu for quickly navigation between headings in the
|
||||
document.
|
||||
document. The edit button on the right will open the viewed document in the editor.
|
||||
|
||||
At the bottom of the view panel there is a **References** panel. (If it is hidden, click the button
|
||||
on the left side of the footer area to reveal it.) This panel contains a References tab with links
|
||||
@@ -86,12 +105,14 @@ you have created root folders and tags for various story elements like character
|
||||
these will appear as additional tabs in this panel.
|
||||
|
||||
.. note::
|
||||
|
||||
The **References** panel relies on an up-to-date :term:`index<project index>` of the project.
|
||||
The index is maintained automatically. However, if anything is missing, or seems wrong, the
|
||||
index can always be rebuilt by selecting **Rebuild Index** from the **Tools** menu, or by
|
||||
pressing :kbd:`F9`.
|
||||
|
||||
.. versionadded:: 2.2
|
||||
|
||||
The reference panel was redesigned and the additional tabs added.
|
||||
|
||||
|
||||
@@ -118,10 +139,8 @@ document when reaching the end. There is also a switch that will try to match th
|
||||
when the replacement is made. That is, it will try to keep the word upper, lower, or capitalised to
|
||||
match the word being replaced.
|
||||
|
||||
The regular expression search is somewhat dependant on which version of Qt your system has. If you
|
||||
have Qt 5.13 or higher, there is better support for Unicode symbols in the search.
|
||||
|
||||
.. seealso::
|
||||
|
||||
For more information on the capabilities of the Regular Expression option, see the Qt
|
||||
documentation for the `QRegularExpression <https://doc.qt.io/qt-5/qregularexpression.html>`_
|
||||
class.
|
||||
@@ -136,6 +155,7 @@ A few auto-replace features are supported by the editor. You can control every a
|
||||
auto-replace feature from **Preferences**. You can also disable this feature entirely if you wish.
|
||||
|
||||
.. tip::
|
||||
|
||||
If you don't like auto-replacement, all symbols inserted by this feature are also available in
|
||||
the :guilabel:`Insert` menu, and via :ref:`a_kb_ins`. You may also be using a `Compose Key`_
|
||||
setup, which means you may not need the auto-replace feature at all.
|
||||
@@ -147,6 +167,22 @@ always accurate. Especially distinguishing between closing single quote and apos
|
||||
tricky for languages that use the same symbol for these, like English does.
|
||||
|
||||
.. tip::
|
||||
|
||||
If the auto-replace feature changes a symbol when you did not want it to change, pressing
|
||||
:kbd:`Ctrl+Z` once after the auto-replacement will undo it without undoing the character
|
||||
you typed before it.
|
||||
|
||||
|
||||
.. _a_ui_edit_dialogue:
|
||||
|
||||
Dialogue Highlighting
|
||||
=====================
|
||||
|
||||
The editor and viewer can optionally highlight character dialogue in your novel documents. By
|
||||
default, this feature is set to display text in double quotes as dialogue. In **Preferences**, you
|
||||
can change this to support various other dialogue markers that instead uses dashes to indicate
|
||||
dialogue, like those used in Spanish, Portuguese, Polish, etc.
|
||||
|
||||
Various styles are supported by changing the settings under "Text Highlighting" in **Preferences**.
|
||||
|
||||
It is also possible to include the dialogue highlighting in your manuscripts.
|
||||
|
||||
2828
i18n/nw_base.ts
2716
i18n/nw_de_DE.ts
2692
i18n/nw_en_US.ts
3079
i18n/nw_es_419.ts
2655
i18n/nw_fr_FR.ts
2692
i18n/nw_it_IT.ts
2696
i18n/nw_ja_JP.ts
2692
i18n/nw_nb_NO.ts
2655
i18n/nw_nl_NL.ts
2690
i18n/nw_pl_PL.ts
2704
i18n/nw_pt_BR.ts
5067
i18n/nw_ru_RU.ts
Normal file
2692
i18n/nw_zh_CN.ts
@@ -3,10 +3,10 @@ novelWriter – Init File
|
||||
=======================
|
||||
|
||||
File History:
|
||||
Created: 2018-09-22 [0.0.1]
|
||||
Created: 2018-09-22 [0.0.1] main
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2018 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -47,9 +47,9 @@ __license__ = "GPLv3"
|
||||
__author__ = "Veronica Berglyd Olsen"
|
||||
__maintainer__ = "Veronica Berglyd Olsen"
|
||||
__email__ = "code@vkbo.net"
|
||||
__version__ = "2.5"
|
||||
__hexversion__ = "0x020500f0"
|
||||
__date__ = "2024-07-10"
|
||||
__version__ = "2.6"
|
||||
__hexversion__ = "0x020600f0"
|
||||
__date__ = "2025-01-26"
|
||||
__status__ = "Stable"
|
||||
__domain__ = "novelwriter.io"
|
||||
|
||||
@@ -60,10 +60,26 @@ logger = logging.getLogger(__name__)
|
||||
# Main Program
|
||||
##
|
||||
|
||||
# Global config and data singletons
|
||||
# Globals Singletons
|
||||
CONFIG = Config()
|
||||
SHARED = SharedData()
|
||||
|
||||
# ANSI Colours
|
||||
C_RED = "\033[91m"
|
||||
C_GREEN = "\033[92m"
|
||||
C_YELLOW = "\033[93m"
|
||||
C_BLUE = "\033[94m"
|
||||
C_WHITE = "\033[97m"
|
||||
C_END = "\033[0m"
|
||||
|
||||
# Log Formats
|
||||
L_TIME = "[{asctime:}]"
|
||||
L_FILE = "{filename:>18}"
|
||||
L_LINE = "{lineno:<4d}"
|
||||
L_LVLP = "{levelname:8}"
|
||||
L_LVLC = "{levelname:17}"
|
||||
L_TEXT = "{message:}"
|
||||
|
||||
|
||||
def main(sysArgs: list | None = None) -> GuiMain | None:
|
||||
"""Parse command line, set up logging, and launch main GUI."""
|
||||
@@ -77,6 +93,7 @@ def main(sysArgs: list | None = None) -> GuiMain | None:
|
||||
"version",
|
||||
"info",
|
||||
"debug",
|
||||
"color",
|
||||
"style=",
|
||||
"config=",
|
||||
"data=",
|
||||
@@ -98,6 +115,7 @@ def main(sysArgs: list | None = None) -> GuiMain | None:
|
||||
" -v, --version Print program version and exit.\n"
|
||||
" --info Print additional runtime information.\n"
|
||||
" --debug Print debug output. Includes --info.\n"
|
||||
" --color Add ANSI colors to log output.\n"
|
||||
" --meminfo Show memory usage information in the status bar.\n"
|
||||
" --style= Sets Qt5 style flag. Defaults to 'Fusion'.\n"
|
||||
" --config= Alternative config file.\n"
|
||||
@@ -106,12 +124,12 @@ def main(sysArgs: list | None = None) -> GuiMain | None:
|
||||
|
||||
# Defaults
|
||||
logLevel = logging.WARN
|
||||
logFormat = "{levelname:8} {message:}"
|
||||
fmtFlags = 0b00
|
||||
confPath = None
|
||||
dataPath = None
|
||||
testMode = False
|
||||
qtStyle = "Fusion"
|
||||
cmdOpen = None
|
||||
qtStyle = "Fusion"
|
||||
cmdOpen = None
|
||||
|
||||
# Parse Options
|
||||
try:
|
||||
@@ -135,8 +153,10 @@ def main(sysArgs: list | None = None) -> GuiMain | None:
|
||||
logLevel = logging.INFO
|
||||
elif inOpt == "--debug":
|
||||
CONFIG.isDebug = True
|
||||
fmtFlags = fmtFlags | 0b10
|
||||
logLevel = logging.DEBUG
|
||||
logFormat = "[{asctime:}] {filename:>18}:{lineno:<4d} {levelname:8} {message:}"
|
||||
elif inOpt == "--color":
|
||||
fmtFlags = fmtFlags | 0b01
|
||||
elif inOpt == "--style":
|
||||
qtStyle = inArg
|
||||
elif inOpt == "--config":
|
||||
@@ -148,13 +168,32 @@ def main(sysArgs: list | None = None) -> GuiMain | None:
|
||||
elif inOpt == "--meminfo":
|
||||
CONFIG.memInfo = True
|
||||
|
||||
if fmtFlags & 0b01:
|
||||
# This will overwrite the default level names, and also ensure that
|
||||
# they can be converted back to integer levels
|
||||
logging.addLevelName(logging.DEBUG, f"{C_BLUE}DEBUG{C_END}")
|
||||
logging.addLevelName(logging.INFO, f"{C_GREEN}INFO{C_END}")
|
||||
logging.addLevelName(logging.WARNING, f"{C_YELLOW}WARNING{C_END}")
|
||||
logging.addLevelName(logging.ERROR, f"{C_RED}ERROR{C_END}")
|
||||
logging.addLevelName(logging.CRITICAL, f"{C_RED}CRITICAL{C_END}")
|
||||
|
||||
# Determine Log Format
|
||||
if fmtFlags == 0b00:
|
||||
logFmt = f"{L_LVLP} {L_TEXT}"
|
||||
elif fmtFlags == 0b01:
|
||||
logFmt = f"{L_LVLC} {L_TEXT}"
|
||||
elif fmtFlags == 0b10:
|
||||
logFmt = f"{L_TIME} {L_FILE}:{L_LINE} {L_LVLP} {L_TEXT}"
|
||||
elif fmtFlags == 0b11:
|
||||
logFmt = f"{L_TIME} {C_BLUE}{L_FILE}{C_END}:{C_WHITE}{L_LINE}{C_END} {L_LVLC} {L_TEXT}"
|
||||
|
||||
# Setup Logging
|
||||
pkgLogger = logging.getLogger(__package__)
|
||||
pkgLogger.setLevel(logLevel)
|
||||
if len(pkgLogger.handlers) == 0:
|
||||
# Make sure we only create one logger (mostly an issue with tests)
|
||||
cHandle = logging.StreamHandler()
|
||||
cHandle.setFormatter(logging.Formatter(fmt=logFormat, style="{"))
|
||||
cHandle.setFormatter(logging.Formatter(fmt=logFmt, style="{"))
|
||||
pkgLogger.addHandler(cHandle)
|
||||
|
||||
logger.info("Starting novelWriter %s (%s) %s", __version__, __hexversion__, __date__)
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
{
|
||||
"Synopsis": "Synopsis",
|
||||
"Short Description": "Kurzbeschreibung",
|
||||
"Footnotes": "Fußnoten",
|
||||
"Comment": "Kommentar",
|
||||
"Notes": "Notizen",
|
||||
"Tag": "Schlagwort",
|
||||
"Point of View": "Perspektive",
|
||||
"Focus": "Mittelpunkt",
|
||||
"Focus": "Fokus",
|
||||
"Characters": "Charaktere",
|
||||
"Plot": "Handlungen",
|
||||
"Plot": "Handlung",
|
||||
"Timeline": "Zeitleiste",
|
||||
"Locations": "Schauplätze",
|
||||
"Objects": "Objekte",
|
||||
"Entities": "Organisationen",
|
||||
"Custom": "Benutzerdefiniert",
|
||||
"New Page": "Neue Seite",
|
||||
"0": "Null",
|
||||
"1": "Eins",
|
||||
"2": "Zwei",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"Objects": "Objects",
|
||||
"Entities": "Entities",
|
||||
"Custom": "Custom",
|
||||
"New Page": "New Page",
|
||||
"0": "Zero",
|
||||
"1": "One",
|
||||
"2": "Two",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"Synopsis": "Synopsis",
|
||||
"Short Description": "Short Description",
|
||||
"Footnotes": "Footnotes",
|
||||
"Comment": "Comment",
|
||||
"Notes": "Notes",
|
||||
"Tag": "Tag",
|
||||
@@ -13,6 +14,7 @@
|
||||
"Objects": "Objects",
|
||||
"Entities": "Entities",
|
||||
"Custom": "Custom",
|
||||
"New Page": "New Page",
|
||||
"0": "Zero",
|
||||
"1": "One",
|
||||
"2": "Two",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"Synopsis": "Sinossi",
|
||||
"Short Description": "Breve descrizione",
|
||||
"Footnotes": "Note a piè pagina",
|
||||
"Comment": "Commento",
|
||||
"Notes": "Note",
|
||||
"Tag": "Etichetta",
|
||||
@@ -13,6 +14,7 @@
|
||||
"Objects": "Oggetti",
|
||||
"Entities": "Enti",
|
||||
"Custom": "Personalizzato",
|
||||
"New Page": "Nuova pagina",
|
||||
"0": "Zero",
|
||||
"1": "Uno",
|
||||
"2": "Due",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"Synopsis": "あらすじ",
|
||||
"Short Description": "短い説明",
|
||||
"Footnotes": "脚注",
|
||||
"Comment": "コメント ",
|
||||
"Notes": "ノート",
|
||||
"Tag": "タグ",
|
||||
@@ -13,6 +14,7 @@
|
||||
"Objects": "オブジェクト",
|
||||
"Entities": "エンティティ",
|
||||
"Custom": "カスタム",
|
||||
"New Page": "新規ページ",
|
||||
"0": "零",
|
||||
"1": "一",
|
||||
"2": "二",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"Synopsis": "Sammendrag",
|
||||
"Short Description": "Kort beskrivelse",
|
||||
"Footnotes": "Fotnoter",
|
||||
"Comment": "Kommentar",
|
||||
"Notes": "Notat",
|
||||
"Tag": "Knagg",
|
||||
@@ -13,6 +14,7 @@
|
||||
"Objects": "Objekter",
|
||||
"Entities": "Enheter",
|
||||
"Custom": "Annet",
|
||||
"New Page": "Ny side",
|
||||
"0": "null",
|
||||
"1": "én",
|
||||
"2": "to",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"Synopsis": "Synopsis",
|
||||
"Short Description": "Korte beschrijving",
|
||||
"Footnotes": "Voetnoten",
|
||||
"Comment": "Opmerking",
|
||||
"Notes": "Notities",
|
||||
"Tag": "Label",
|
||||
@@ -13,6 +14,7 @@
|
||||
"Objects": "Objecten",
|
||||
"Entities": "Entiteiten",
|
||||
"Custom": "Aangepast",
|
||||
"New Page": "Nieuwe pagina",
|
||||
"0": "nul",
|
||||
"1": "één",
|
||||
"2": "twee",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"Synopsis": "Streszczenie",
|
||||
"Short Description": "Krótki opis",
|
||||
"Footnotes": "Przypisy",
|
||||
"Comment": "Komentarz",
|
||||
"Notes": "Notatki",
|
||||
"Tag": "Znacznik",
|
||||
@@ -13,6 +14,7 @@
|
||||
"Objects": "Obiekty",
|
||||
"Entities": "Podmioty",
|
||||
"Custom": "Różne",
|
||||
"New Page": "Nowa strona",
|
||||
"0": "zero",
|
||||
"1": "pierwszy",
|
||||
"2": "drugi",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"Synopsis": "Sinopse",
|
||||
"Short Description": "Descrição breve",
|
||||
"Footnotes": "Notas de rodapé",
|
||||
"Comment": "Comentário",
|
||||
"Notes": "Notas",
|
||||
"Tag": "Etiqueta",
|
||||
@@ -13,6 +14,7 @@
|
||||
"Objects": "Objetos",
|
||||
"Entities": "Entidades",
|
||||
"Custom": "Outros",
|
||||
"New Page": "Nova página",
|
||||
"0": "Zero",
|
||||
"1": "Um",
|
||||
"2": "Dois",
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
{
|
||||
"Synopsis": "Сводка",
|
||||
"Short Description": "Краткое описание",
|
||||
"Comment": "Комментарий",
|
||||
"Notes": "Заметки",
|
||||
"Tag": "Тэг",
|
||||
"Point of View": "Точка зрения",
|
||||
"Focus": "Фокус",
|
||||
"Characters": "Персонажей",
|
||||
"Plot": "Сюжет",
|
||||
"Timeline": "Хронология",
|
||||
"Locations": "Локации",
|
||||
"Objects": "Объекты",
|
||||
"Entities": "Сущности",
|
||||
"Custom": "Другое",
|
||||
"0": "Ноль",
|
||||
"1": "Один",
|
||||
"2": "Два",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"Synopsis": "概要",
|
||||
"Short Description": "简短描述",
|
||||
"Footnotes": "注释",
|
||||
"Comment": "注释",
|
||||
"Notes": "笔记",
|
||||
"Tag": "标签",
|
||||
@@ -13,6 +14,7 @@
|
||||
"Objects": "物品",
|
||||
"Entities": "条目",
|
||||
"Custom": "自定义",
|
||||
"New Page": "新建页面",
|
||||
"0": "零",
|
||||
"1": "一",
|
||||
"2": "二",
|
||||
|
||||
@@ -44,6 +44,7 @@ cls_template = mixed_document-new.svg
|
||||
cls_timeline = typ_calendar.svg
|
||||
cls_trash = typ_trash.svg
|
||||
cls_world = typ_location.svg
|
||||
copy = mixed_copy.svg
|
||||
cross = typ_times.svg
|
||||
document = typ_document.svg
|
||||
down = typ_chevron-down.svg
|
||||
@@ -63,6 +64,10 @@ font = nw_font.svg
|
||||
forward = typ_chevron-right.svg
|
||||
import = mixed_import.svg
|
||||
list = typ_th-list.svg
|
||||
margin_bottom = mixed_margin-bottom.svg
|
||||
margin_left = mixed_margin-left.svg
|
||||
margin_right = mixed_margin-right.svg
|
||||
margin_top = mixed_margin-top.svg
|
||||
maximise = typ_arrow-maximise.svg
|
||||
menu = typ_th-dot-menu.svg
|
||||
minimise = typ_arrow-minimise.svg
|
||||
@@ -93,6 +98,8 @@ search_regex = nw_search-regex.svg
|
||||
search_replace = mixed_search-replace.svg
|
||||
search_word = nw_search-word.svg
|
||||
settings = typ_cog.svg
|
||||
size_height = mixed_size-height.svg
|
||||
size_width = mixed_size-width.svg
|
||||
star = typ_star.svg
|
||||
status_idle = typ_media-pause-grey.svg
|
||||
status_lang = typ_globe-grey.svg
|
||||
@@ -101,6 +108,7 @@ status_stats = typ_chart-bar-grey.svg
|
||||
status_time = typ_stopwatch-grey.svg
|
||||
sticky-off = typ_pin-outline.svg
|
||||
sticky-on = typ_pin.svg
|
||||
toolbar = nw_toolbar.svg
|
||||
unchecked = mixed_input-unchecked.svg
|
||||
unfold-hide = typ_unfold-hidden.svg
|
||||
unfold-show = typ_unfold-visible.svg
|
||||
|
||||
4
novelwriter/assets/icons/typicons_dark/mixed_copy.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m7.8824 2c-1.2988 0-2.3529 1.0541-2.3529 2.3529v2.3529h-1.1765c-1.2988 0-2.3529 1.0541-2.3529 2.3529v10.588c0 1.2988 1.0541 2.3529 2.3529 2.3529h10.588c1.2988 0 2.3529-1.0541 2.3529-2.3529v-1.1765h2.3529c1.2988 0 2.3529-1.0541 2.3529-2.3529v-11.765c0-1.2988-1.0541-2.3529-2.3529-2.3529zm0 2.3529h11.765v11.765h-2.3529v-7.0588c0-1.2988-1.0541-2.3529-2.3529-2.3529h-7.0588zm-3.5294 4.7059h10.588c0.32353 0 0-0.32353 0 0v10.588h-10.588v-9.8943z" fill="#69c" stroke-width="1.1765"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 633 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#e6e6e6" fill-opacity=".14902" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="2" y="18" width="20" height="4" rx="1" ry="1" fill="#848484" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m18.414 8.5959c0.78104 0.79361 0.78104 2.0837 0 2.8773l-6.4141 6.5268-6.4141-6.5268c-0.391-0.3968-0.58601-0.91769-0.58601-1.4386 0-0.52092 0.195-1.0418 0.58601-1.4386 0.78104-0.79464 2.047-0.79464 2.8281 0l3.5861 3.6475 3.5861-3.6475c0.78104-0.79464 2.047-0.79464 2.8281 0z" fill="#69c" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 713 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#e6e6e6" fill-opacity=".14902" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="2" y="2" width="4" height="20" rx="1" ry="1" fill="#848484" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m15.404 18.5c-0.79361 0.78104-2.0837 0.78104-2.8773 0l-6.5268-6.4141 6.5268-6.4141c0.3968-0.391 0.91769-0.58601 1.4386-0.58601 0.52092 0 1.0418 0.195 1.4386 0.58601 0.79464 0.78104 0.79464 2.047 0 2.8281l-3.6475 3.5861 3.6475 3.5861c0.79464 0.78104 0.79464 2.047 0 2.8281z" fill="#69c" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 711 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#e6e6e6" fill-opacity=".14902" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="18" y="2" width="4" height="20" rx="1" ry="1" fill="#848484" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m8.5959 18.5c0.79361 0.78104 2.0837 0.78104 2.8773 0l6.5268-6.4141-6.5268-6.4141c-0.3968-0.391-0.91769-0.58601-1.4386-0.58601-0.52092 0-1.0418 0.195-1.4386 0.58601-0.79464 0.78104-0.79464 2.047 0 2.8281l3.6475 3.5861-3.6475 3.5861c-0.79464 0.78104-0.79464 2.047 0 2.8281z" fill="#69c" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 711 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#e6e6e6" fill-opacity=".14902" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="2" y="2" width="20" height="4" rx="1" ry="1" fill="#848484" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m18.414 15.404c0.78104-0.79361 0.78104-2.0837 0-2.8773l-6.4141-6.5268-6.4141 6.5268c-0.391 0.3968-0.58601 0.91769-0.58601 1.4386 0 0.52092 0.195 1.0418 0.58601 1.4386 0.78104 0.79464 2.047 0.79464 2.8281 0l3.5861-3.6475 3.5861 3.6475c0.78104 0.79464 2.047 0.79464 2.8281 0z" fill="#69c" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 712 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#e6e6e6" fill-opacity=".14902" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m18.414 10.404c0.78104-0.79361 0.78104-2.0837 0-2.8773l-6.4141-6.5268-6.4141 6.5268c-0.391 0.3968-0.58601 0.91769-0.58601 1.4386 0 0.52092 0.195 1.0418 0.58601 1.4386 0.78104 0.79464 2.047 0.79464 2.8281 0l3.5861-3.6475 3.5861 3.6475c0.78104 0.79464 2.047 0.79464 2.8281 0z" fill="#69c" stroke-width="1.0087"/>
|
||||
<path d="m18.414 13.596c0.78104 0.79361 0.78104 2.0837 0 2.8773l-6.4141 6.5268-6.4141-6.5268c-0.391-0.3968-0.58601-0.91769-0.58601-1.4386 0-0.52092 0.195-1.0418 0.58601-1.4386 0.78104-0.79464 2.047-0.79464 2.8281 0l3.5861 3.6475 3.5861-3.6475c0.78104-0.79464 2.047-0.79464 2.8281 0z" fill="#69c" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 921 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#e6e6e6" fill-opacity=".15" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m13.596 18.414c0.79361 0.78104 2.0837 0.78104 2.8773 0l6.5268-6.4141-6.5268-6.4141c-0.3968-0.391-0.91769-0.58601-1.4386-0.58601-0.52092 0-1.0418 0.195-1.4386 0.58601-0.79464 0.78104-0.79464 2.047 0 2.8281l3.6475 3.5861-3.6475 3.5861c-0.79464 0.78104-0.79464 2.047 0 2.8281z" fill="#69c" stroke-width="1.0087"/>
|
||||
<path d="m10.404 18.414c-0.79361 0.78104-2.0837 0.78104-2.8773 0l-6.5268-6.4141 6.5268-6.4141c0.3968-0.391 0.91769-0.58601 1.4386-0.58601 0.52092 0 1.0418 0.195 1.4386 0.58601 0.79464 0.78104 0.79464 2.047 0 2.8281l-3.6475 3.5861 3.6475 3.5861c0.79464 0.78104 0.79464 2.047 0 2.8281z" fill="#69c" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 919 B |
5
novelwriter/assets/icons/typicons_dark/nw_toolbar.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="8" y="2" width="8" height="8" rx="1" ry="1" fill="none" stroke="#69c" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="8" y="14" width="8" height="8" rx="1" ry="1" fill="none" stroke="#69c" stroke-linecap="round" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 389 B |
@@ -44,6 +44,7 @@ cls_template = mixed_document-new.svg
|
||||
cls_timeline = typ_calendar.svg
|
||||
cls_trash = typ_trash.svg
|
||||
cls_world = typ_location.svg
|
||||
copy = mixed_copy.svg
|
||||
cross = typ_times.svg
|
||||
document = typ_document.svg
|
||||
down = typ_chevron-down.svg
|
||||
@@ -63,6 +64,10 @@ font = nw_font.svg
|
||||
forward = typ_chevron-right.svg
|
||||
import = mixed_import.svg
|
||||
list = typ_th-list.svg
|
||||
margin_bottom = mixed_margin-bottom.svg
|
||||
margin_left = mixed_margin-left.svg
|
||||
margin_right = mixed_margin-right.svg
|
||||
margin_top = mixed_margin-top.svg
|
||||
maximise = typ_arrow-maximise.svg
|
||||
menu = typ_th-dot-menu.svg
|
||||
minimise = typ_arrow-minimise.svg
|
||||
@@ -93,6 +98,8 @@ search_regex = nw_search-regex.svg
|
||||
search_replace = mixed_search-replace.svg
|
||||
search_word = nw_search-word.svg
|
||||
settings = typ_cog.svg
|
||||
size_height = mixed_size-height.svg
|
||||
size_width = mixed_size-width.svg
|
||||
star = typ_star.svg
|
||||
status_idle = typ_media-pause-grey.svg
|
||||
status_lang = typ_globe-grey.svg
|
||||
@@ -101,6 +108,7 @@ status_stats = typ_chart-bar-grey.svg
|
||||
status_time = typ_stopwatch-grey.svg
|
||||
sticky-off = typ_pin-outline.svg
|
||||
sticky-on = typ_pin.svg
|
||||
toolbar = nw_toolbar.svg
|
||||
unchecked = mixed_input-unchecked.svg
|
||||
unfold-hide = typ_unfold-hidden.svg
|
||||
unfold-show = typ_unfold-visible.svg
|
||||
|
||||
4
novelwriter/assets/icons/typicons_light/mixed_copy.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m7.8824 2c-1.2988 0-2.3529 1.0541-2.3529 2.3529v2.3529h-1.1765c-1.2988 0-2.3529 1.0541-2.3529 2.3529v10.588c0 1.2988 1.0541 2.3529 2.3529 2.3529h10.588c1.2988 0 2.3529-1.0541 2.3529-2.3529v-1.1765h2.3529c1.2988 0 2.3529-1.0541 2.3529-2.3529v-11.765c0-1.2988-1.0541-2.3529-2.3529-2.3529zm0 2.3529h11.765v11.765h-2.3529v-7.0588c0-1.2988-1.0541-2.3529-2.3529-2.3529h-7.0588zm-3.5294 4.7059h10.588c0.32353 0 0-0.32353 0 0v10.588h-10.588v-9.8943z" fill="#4271ae" stroke-width="1.1765"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 636 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#333" fill-opacity=".15" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="2" y="18" width="20" height="4" rx="1" ry="1" fill="#333" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m18.414 8.5959c0.78104 0.79361 0.78104 2.0837 0 2.8773l-6.4141 6.5268-6.4141-6.5268c-0.391-0.3968-0.58601-0.91769-0.58601-1.4386 0-0.52092 0.195-1.0418 0.58601-1.4386 0.78104-0.79464 2.047-0.79464 2.8281 0l3.5861 3.6475 3.5861-3.6475c0.78104-0.79464 2.047-0.79464 2.8281 0z" fill="#4271ae" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 707 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#333" fill-opacity=".15" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="2" y="2" width="4" height="20" rx="1" ry="1" fill="#333" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m15.404 18.5c-0.79361 0.78104-2.0837 0.78104-2.8773 0l-6.5268-6.4141 6.5268-6.4141c0.3968-0.391 0.91769-0.58601 1.4386-0.58601 0.52092 0 1.0418 0.195 1.4386 0.58601 0.79464 0.78104 0.79464 2.047 0 2.8281l-3.6475 3.5861 3.6475 3.5861c0.79464 0.78104 0.79464 2.047 0 2.8281z" fill="#4271ae" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 705 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#333" fill-opacity=".15" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="18" y="2" width="4" height="20" rx="1" ry="1" fill="#333" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m8.5959 18.5c0.79361 0.78104 2.0837 0.78104 2.8773 0l6.5268-6.4141-6.5268-6.4141c-0.3968-0.391-0.91769-0.58601-1.4386-0.58601-0.52092 0-1.0418 0.195-1.4386 0.58601-0.79464 0.78104-0.79464 2.047 0 2.8281l3.6475 3.5861-3.6475 3.5861c-0.79464 0.78104-0.79464 2.047 0 2.8281z" fill="#4271ae" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 705 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#333" fill-opacity=".15" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="2" y="2" width="20" height="4" rx="1" ry="1" fill="#333" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m18.414 15.404c0.78104-0.79361 0.78104-2.0837 0-2.8773l-6.4141-6.5268-6.4141 6.5268c-0.391 0.3968-0.58601 0.91769-0.58601 1.4386 0 0.52092 0.195 1.0418 0.58601 1.4386 0.78104 0.79464 2.047 0.79464 2.8281 0l3.5861-3.6475 3.5861 3.6475c0.78104 0.79464 2.047 0.79464 2.8281 0z" fill="#4271ae" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 706 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#333" fill-opacity=".15" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m18.414 10.404c0.78104-0.79361 0.78104-2.0837 0-2.8773l-6.4141-6.5268-6.4141 6.5268c-0.391 0.3968-0.58601 0.91769-0.58601 1.4386 0 0.52092 0.195 1.0418 0.58601 1.4386 0.78104 0.79464 2.047 0.79464 2.8281 0l3.5861-3.6475 3.5861 3.6475c0.78104 0.79464 2.047 0.79464 2.8281 0z" fill="#4271ae" stroke-width="1.0087"/>
|
||||
<path d="m18.414 13.596c0.78104 0.79361 0.78104 2.0837 0 2.8773l-6.4141 6.5268-6.4141-6.5268c-0.391-0.3968-0.58601-0.91769-0.58601-1.4386 0-0.52092 0.195-1.0418 0.58601-1.4386 0.78104-0.79464 2.047-0.79464 2.8281 0l3.5861 3.6475 3.5861-3.6475c0.78104-0.79464 2.047-0.79464 2.8281 0z" fill="#4271ae" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 921 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#333" fill-opacity=".15" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m13.596 18.414c0.79361 0.78104 2.0837 0.78104 2.8773 0l6.5268-6.4141-6.5268-6.4141c-0.3968-0.391-0.91769-0.58601-1.4386-0.58601-0.52092 0-1.0418 0.195-1.4386 0.58601-0.79464 0.78104-0.79464 2.047 0 2.8281l3.6475 3.5861-3.6475 3.5861c-0.79464 0.78104-0.79464 2.047 0 2.8281z" fill="#4271ae" stroke-width="1.0087"/>
|
||||
<path d="m10.404 18.414c-0.79361 0.78104-2.0837 0.78104-2.8773 0l-6.5268-6.4141 6.5268-6.4141c0.3968-0.391 0.91769-0.58601 1.4386-0.58601 0.52092 0 1.0418 0.195 1.4386 0.58601 0.79464 0.78104 0.79464 2.047 0 2.8281l-3.6475 3.5861 3.6475 3.5861c0.79464 0.78104 0.79464 2.047 0 2.8281z" fill="#4271ae" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 922 B |
5
novelwriter/assets/icons/typicons_light/nw_toolbar.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="8" y="2" width="8" height="8" rx="1" ry="1" fill="none" stroke="#4271ae" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="8" y="14" width="8" height="8" rx="1" ry="1" fill="none" stroke="#4271ae" stroke-linecap="round" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 395 B |
@@ -35,6 +35,7 @@ translators for the languages currently available:</p>
|
||||
<li><b>Norwegian:</b> Veronica Berglyd Olsen (vkbo)</li>
|
||||
<li><b>Polish:</b> Anna Maria Polak (Nauthiz)</li>
|
||||
<li><b>Portuguese:</b> Bruno Meneguello (bkmeneguello)</li>
|
||||
<li><b>Russian:</b> Konstantin Tan (k1kimosha)</li>
|
||||
<li><b>Simplified Chinese:</b> Qianzhi Long (longqzh)</li>
|
||||
</ul>
|
||||
|
||||
|
||||
@@ -29,5 +29,5 @@ helptext = 92, 92, 92
|
||||
fadedtext = 108, 108, 108
|
||||
errortext = 255, 92, 92
|
||||
statusnone = 120, 120, 120
|
||||
statussaved = 200, 15, 39
|
||||
statusunsaved = 2, 133, 37
|
||||
statussaved = 2, 133, 37
|
||||
statusunsaved = 200, 15, 39
|
||||
|
||||
@@ -6,7 +6,7 @@ File History:
|
||||
Created: 2019-05-12 [0.1.0]
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2019 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -29,6 +29,7 @@ import unicodedata
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from collections.abc import Callable
|
||||
from configparser import ConfigParser
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -36,8 +37,8 @@ from typing import TYPE_CHECKING, Any, Literal, TypeVar
|
||||
from urllib.parse import urljoin
|
||||
from urllib.request import pathname2url
|
||||
|
||||
from PyQt5.QtCore import QCoreApplication, QUrl
|
||||
from PyQt5.QtGui import QColor, QDesktopServices, QFont, QFontInfo
|
||||
from PyQt5.QtCore import QCoreApplication, QMimeData, QUrl
|
||||
from PyQt5.QtGui import QColor, QDesktopServices, QFont, QFontDatabase, QFontInfo
|
||||
|
||||
from novelwriter.constants import nwConst, nwLabels, nwUnicode, trConst
|
||||
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
|
||||
@@ -204,6 +205,14 @@ def checkIntTuple(value: int, valid: tuple | list | set, default: int) -> int:
|
||||
return default
|
||||
|
||||
|
||||
def firstFloat(*args: Any) -> float:
|
||||
"""Return the first value that is a float."""
|
||||
for arg in args:
|
||||
if isinstance(arg, float):
|
||||
return arg
|
||||
return 0.0
|
||||
|
||||
|
||||
##
|
||||
# Formatting Functions
|
||||
##
|
||||
@@ -279,6 +288,16 @@ def simplified(text: str) -> str:
|
||||
return " ".join(str(text).strip().split())
|
||||
|
||||
|
||||
def compact(text: str) -> str:
|
||||
"""Compact a string by removing spaces."""
|
||||
return "".join(str(text).split())
|
||||
|
||||
|
||||
def uniqueCompact(text: str) -> str:
|
||||
"""Return a unique, compact and sorted string."""
|
||||
return "".join(sorted(set(compact(text))))
|
||||
|
||||
|
||||
def elide(text: str, length: int) -> str:
|
||||
"""Elide a piece of text to a maximum length."""
|
||||
if len(text) > (cut := max(4, length)):
|
||||
@@ -396,6 +415,10 @@ def numberToRoman(value: int, toLower: bool = False) -> str:
|
||||
return roman.lower() if toLower else roman
|
||||
|
||||
|
||||
##
|
||||
# Qt Helpers
|
||||
##
|
||||
|
||||
def cssCol(col: QColor, alpha: int | None = None) -> str:
|
||||
"""Convert a QColor object to an rgba entry to use in CSS."""
|
||||
return f"rgba({col.red()}, {col.green()}, {col.blue()}, {alpha or col.alpha()})"
|
||||
@@ -411,6 +434,48 @@ def describeFont(font: QFont) -> str:
|
||||
return "Error"
|
||||
|
||||
|
||||
def fontMatcher(font: QFont) -> QFont:
|
||||
"""Make sure the font is the correct family, if possible. This
|
||||
ensures that Qt doesn't re-use another font under the hood. The
|
||||
default Qt5 font matching algorithm doesn't handle well changing
|
||||
application fonts at runtime.
|
||||
"""
|
||||
info = QFontInfo(font)
|
||||
if (famRequest := font.family()) != (famActual := info.family()):
|
||||
logger.warning("Font mismatch: Requested '%s', but got '%s'", famRequest, famActual)
|
||||
db = QFontDatabase()
|
||||
if famRequest in db.families():
|
||||
styleRequest, sizeRequest = font.styleName(), font.pointSize()
|
||||
logger.info("Lookup: %s, %s, %d pt", famRequest, styleRequest, sizeRequest)
|
||||
temp = db.font(famRequest, styleRequest, sizeRequest)
|
||||
temp.setPointSize(sizeRequest) # Make sure it isn't changed
|
||||
famFound, styleFound, sizeFound = temp.family(), temp.styleName(), temp.pointSize()
|
||||
if famFound == famRequest:
|
||||
logger.info("Found: %s, %s, %d pt", famFound, styleFound, sizeFound)
|
||||
return temp
|
||||
logger.warning("Could not find a font match in the font database")
|
||||
logger.warning("If you just changed font, you may need to restart the application")
|
||||
return font
|
||||
|
||||
|
||||
def qtLambda(func: Callable, *args: Any, **kwargs: Any) -> Callable:
|
||||
"""A replacement for Python lambdas that works for Qt slots."""
|
||||
def wrapper(*a_: Any) -> None:
|
||||
func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
def encodeMimeHandles(mimeData: QMimeData, handles: list[str]) -> None:
|
||||
"""Encode handles into a mime data object."""
|
||||
mimeData.setData(nwConst.MIME_HANDLE, b"|".join(h.encode() for h in handles))
|
||||
return
|
||||
|
||||
|
||||
def decodeMimeHandles(mimeData: QMimeData) -> list[str]:
|
||||
"""Decode and split a mime data object with handles."""
|
||||
return mimeData.data(nwConst.MIME_HANDLE).data().decode().split("|")
|
||||
|
||||
|
||||
##
|
||||
# Encoder Functions
|
||||
##
|
||||
@@ -462,6 +527,10 @@ def jsonEncode(data: dict | list | tuple, n: int = 0, nmax: int = 0) -> str:
|
||||
return "".join(buffer)
|
||||
|
||||
|
||||
##
|
||||
# XML Helpers
|
||||
##
|
||||
|
||||
def xmlIndent(tree: ET.Element | ET.ElementTree) -> None:
|
||||
"""A modified version of the XML indent function in the standard
|
||||
library. It behaves more closely to how the one from lxml does.
|
||||
@@ -505,6 +574,45 @@ def xmlIndent(tree: ET.Element | ET.ElementTree) -> None:
|
||||
return
|
||||
|
||||
|
||||
def xmlElement(
|
||||
tag: str,
|
||||
text: str | int | float | bool | None = None,
|
||||
*,
|
||||
attrib: dict | None = None,
|
||||
tail: str | None = None,
|
||||
) -> ET.Element:
|
||||
"""A custom implementation of Element with more arguments."""
|
||||
xSub = ET.Element(tag, attrib=attrib or {})
|
||||
if text is not None:
|
||||
if isinstance(text, bool):
|
||||
xSub.text = str(text).lower()
|
||||
else:
|
||||
xSub.text = str(text)
|
||||
if tail is not None:
|
||||
xSub.tail = tail
|
||||
return xSub
|
||||
|
||||
|
||||
def xmlSubElem(
|
||||
parent: ET.Element,
|
||||
tag: str,
|
||||
text: str | int | float | bool | None = None,
|
||||
*,
|
||||
attrib: dict | None = None,
|
||||
tail: str | None = None,
|
||||
) -> ET.Element:
|
||||
"""A custom implementation of SubElement with more arguments."""
|
||||
xSub = ET.SubElement(parent, tag, attrib=attrib or {})
|
||||
if text is not None:
|
||||
if isinstance(text, bool):
|
||||
xSub.text = str(text).lower()
|
||||
else:
|
||||
xSub.text = str(text)
|
||||
if tail is not None:
|
||||
xSub.tail = tail
|
||||
return xSub
|
||||
|
||||
|
||||
##
|
||||
# File and File System Functions
|
||||
##
|
||||
|
||||
@@ -8,7 +8,7 @@ Created: 2022-11-09 [2.0rc2] RecentProjects
|
||||
Created: 2024-06-16 [2.5rc1] RecentPaths
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2018 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -40,7 +40,10 @@ from PyQt5.QtCore import (
|
||||
from PyQt5.QtGui import QFont, QFontDatabase
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
from novelwriter.common import NWConfigParser, checkInt, checkPath, describeFont, formatTimeStamp
|
||||
from novelwriter.common import (
|
||||
NWConfigParser, checkInt, checkPath, describeFont, fontMatcher,
|
||||
formatTimeStamp
|
||||
)
|
||||
from novelwriter.constants import nwFiles, nwUnicode
|
||||
from novelwriter.error import formatException, logException
|
||||
|
||||
@@ -109,7 +112,7 @@ class Config:
|
||||
|
||||
# General GUI Settings
|
||||
self.guiLocale = self._qLocale.name()
|
||||
self.guiTheme = "default" # GUI theme
|
||||
self.guiTheme = "default_light" # GUI theme
|
||||
self.guiSyntax = "default_light" # Syntax theme
|
||||
self.guiFont = QFont() # Main GUI font
|
||||
self.guiScale = 1.0 # Set automatically by Theme class
|
||||
@@ -161,8 +164,9 @@ class Config:
|
||||
|
||||
self.dialogStyle = 2 # Quote type to use for dialogue
|
||||
self.allowOpenDial = True # Allow open-ended dialogue quotes
|
||||
self.narratorBreak = "" # Symbol to use for narrator break
|
||||
self.dialogLine = "" # Symbol to use for dialogue line
|
||||
self.narratorBreak = "" # Symbol to use for narrator break
|
||||
self.narratorDialog = "" # Symbol for alternating between dialogue and narrator
|
||||
self.altDialogOpen = "" # Alternative dialog symbol, open
|
||||
self.altDialogClose = "" # Alternative dialog symbol, close
|
||||
self.highlightEmph = True # Add colour to text emphasis
|
||||
@@ -190,6 +194,7 @@ class Config:
|
||||
# State
|
||||
self.showViewerPanel = True # The panel for the viewer is visible
|
||||
self.showEditToolBar = False # The document editor toolbar visibility
|
||||
self.showSessionTime = True # Show the session time in the status bar
|
||||
self.viewComments = True # Comments are shown in the viewer
|
||||
self.viewSynopsis = True # Synopsis is shown in the viewer
|
||||
|
||||
@@ -252,6 +257,10 @@ class Config:
|
||||
def hasError(self) -> bool:
|
||||
return self._hasError
|
||||
|
||||
@property
|
||||
def locale(self) -> QLocale:
|
||||
return self._dLocale
|
||||
|
||||
@property
|
||||
def recentProjects(self) -> RecentProjects:
|
||||
return self._recentProjects
|
||||
@@ -364,10 +373,11 @@ class Config:
|
||||
def setGuiFont(self, value: QFont | str | None) -> None:
|
||||
"""Update the GUI's font style from settings."""
|
||||
if isinstance(value, QFont):
|
||||
self.guiFont = value
|
||||
self.guiFont = fontMatcher(value)
|
||||
elif value and isinstance(value, str):
|
||||
self.guiFont = QFont()
|
||||
self.guiFont.fromString(value)
|
||||
font = QFont()
|
||||
font.fromString(value)
|
||||
self.guiFont = fontMatcher(font)
|
||||
else:
|
||||
font = QFont()
|
||||
fontDB = QFontDatabase()
|
||||
@@ -377,11 +387,9 @@ class Config:
|
||||
font.setPointSize(10)
|
||||
else:
|
||||
font = fontDB.systemFont(QFontDatabase.SystemFont.GeneralFont)
|
||||
self.guiFont = font
|
||||
self.guiFont = fontMatcher(font)
|
||||
logger.debug("GUI font set to: %s", describeFont(font))
|
||||
|
||||
QApplication.setFont(self.guiFont)
|
||||
|
||||
return
|
||||
|
||||
def setTextFont(self, value: QFont | str | None) -> None:
|
||||
@@ -389,10 +397,11 @@ class Config:
|
||||
set to default font.
|
||||
"""
|
||||
if isinstance(value, QFont):
|
||||
self.textFont = value
|
||||
self.textFont = fontMatcher(value)
|
||||
elif value and isinstance(value, str):
|
||||
self.textFont = QFont()
|
||||
self.textFont.fromString(value)
|
||||
font = QFont()
|
||||
font.fromString(value)
|
||||
self.textFont = fontMatcher(font)
|
||||
else:
|
||||
fontDB = QFontDatabase()
|
||||
fontFam = fontDB.families()
|
||||
@@ -406,8 +415,8 @@ class Config:
|
||||
font.setPointSize(12)
|
||||
else:
|
||||
font = fontDB.systemFont(QFontDatabase.SystemFont.GeneralFont)
|
||||
self.textFont = font
|
||||
logger.debug("Text font set to: %s", describeFont(font))
|
||||
self.textFont = fontMatcher(font)
|
||||
logger.debug("Text font set to: %s", describeFont(self.textFont))
|
||||
return
|
||||
|
||||
##
|
||||
@@ -653,10 +662,11 @@ class Config:
|
||||
self.showFullPath = conf.rdBool(sec, "showfullpath", self.showFullPath)
|
||||
self.dialogStyle = conf.rdInt(sec, "dialogstyle", self.dialogStyle)
|
||||
self.allowOpenDial = conf.rdBool(sec, "allowopendial", self.allowOpenDial)
|
||||
self.dialogLine = conf.rdStr(sec, "dialogline", self.dialogLine)
|
||||
self.narratorBreak = conf.rdStr(sec, "narratorbreak", self.narratorBreak)
|
||||
self.narratorDialog = conf.rdStr(sec, "narratordialog", self.narratorDialog)
|
||||
self.altDialogOpen = conf.rdStr(sec, "altdialogopen", self.altDialogOpen)
|
||||
self.altDialogClose = conf.rdStr(sec, "altdialogclose", self.altDialogClose)
|
||||
self.dialogLine = conf.rdStr(sec, "dialogline", self.dialogLine)
|
||||
self.highlightEmph = conf.rdBool(sec, "highlightemph", self.highlightEmph)
|
||||
self.stopWhenIdle = conf.rdBool(sec, "stopwhenidle", self.stopWhenIdle)
|
||||
self.userIdleTime = conf.rdInt(sec, "useridletime", self.userIdleTime)
|
||||
@@ -665,6 +675,7 @@ class Config:
|
||||
sec = "State"
|
||||
self.showViewerPanel = conf.rdBool(sec, "showviewerpanel", self.showViewerPanel)
|
||||
self.showEditToolBar = conf.rdBool(sec, "showedittoolbar", self.showEditToolBar)
|
||||
self.showSessionTime = conf.rdBool(sec, "showsessiontime", self.showSessionTime)
|
||||
self.viewComments = conf.rdBool(sec, "viewcomments", self.viewComments)
|
||||
self.viewSynopsis = conf.rdBool(sec, "viewsynopsis", self.viewSynopsis)
|
||||
self.searchCase = conf.rdBool(sec, "searchcase", self.searchCase)
|
||||
@@ -762,10 +773,11 @@ class Config:
|
||||
"showfullpath": str(self.showFullPath),
|
||||
"dialogstyle": str(self.dialogStyle),
|
||||
"allowopendial": str(self.allowOpenDial),
|
||||
"dialogline": str(self.dialogLine),
|
||||
"narratorbreak": str(self.narratorBreak),
|
||||
"narratordialog": str(self.narratorDialog),
|
||||
"altdialogopen": str(self.altDialogOpen),
|
||||
"altdialogclose": str(self.altDialogClose),
|
||||
"dialogline": str(self.dialogLine),
|
||||
"highlightemph": str(self.highlightEmph),
|
||||
"stopwhenidle": str(self.stopWhenIdle),
|
||||
"useridletime": str(self.userIdleTime),
|
||||
@@ -774,6 +786,7 @@ class Config:
|
||||
conf["State"] = {
|
||||
"showviewerpanel": str(self.showViewerPanel),
|
||||
"showedittoolbar": str(self.showEditToolBar),
|
||||
"showsessiontime": str(self.showSessionTime),
|
||||
"viewcomments": str(self.viewComments),
|
||||
"viewsynopsis": str(self.viewSynopsis),
|
||||
"searchcase": str(self.searchCase),
|
||||
|
||||
@@ -6,7 +6,7 @@ File History:
|
||||
Created: 2019-04-28 [0.0.1]
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2019 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -53,6 +53,9 @@ class nwConst:
|
||||
# Requests
|
||||
USER_AGENT = "Mozilla/5.0 (compatible; novelWriter (Python))"
|
||||
|
||||
# Mime Types
|
||||
MIME_HANDLE = "text/vnd.novelwriter.handle"
|
||||
|
||||
# Gui Settings
|
||||
STATUS_MSG_TIMEOUT = 15000 # milliseconds
|
||||
MAX_SEARCH_RESULT = 1000
|
||||
@@ -60,11 +63,14 @@ class nwConst:
|
||||
|
||||
class nwRegEx:
|
||||
|
||||
URL = r"https?://(?:www\.|(?!www))[\w/()@:%_\+-.~#?&=]+"
|
||||
WORDS = r"\b[^\s\-\+\/–—\[\]:]+\b"
|
||||
BREAK = r"(?i)(?<!\\)(\[br\]\n?)"
|
||||
FMT_EI = r"(?<![\w\\])(_)(?![\s_])(.+?)(?<![\s\\])(\1)(?!\w)"
|
||||
FMT_EB = r"(?<![\w\\])(\*{2})(?![\s\*])(.+?)(?<![\s\\])(\1)(?!\w)"
|
||||
FMT_ST = r"(?<![\w\\])(~{2})(?![\s~])(.+?)(?<![\s\\])(\1)(?!\w)"
|
||||
FMT_SC = r"(?i)(?<!\\)(\[[\/\!]?(?:b|i|s|u|m|sup|sub)\])"
|
||||
FMT_SV = r"(?i)(?<!\\)(\[(?:footnote):)(.+?)(?<!\\)(\])"
|
||||
FMT_SC = r"(?i)(?<!\\)(\[(?:b|/b|i|/i|s|/s|u|/u|m|/m|sup|/sup|sub|/sub|br)\])"
|
||||
FMT_SV = r"(?i)(?<!\\)(\[(?:footnote|field):)(.+?)(?<!\\)(\])"
|
||||
|
||||
|
||||
class nwShortcode:
|
||||
@@ -83,21 +89,49 @@ class nwShortcode:
|
||||
SUP_C = "[/sup]"
|
||||
SUB_O = "[sub]"
|
||||
SUB_C = "[/sub]"
|
||||
BREAK = "[br]"
|
||||
|
||||
FOOTNOTE_B = "[footnote:"
|
||||
FIELD_B = "[field:"
|
||||
|
||||
COMMENT_STYLES = {
|
||||
nwComment.FOOTNOTE: "[footnote:{0}]",
|
||||
nwComment.COMMENT: "[comment:{0}]",
|
||||
}
|
||||
|
||||
FIELD_VALUE = "[field:{0}]"
|
||||
|
||||
class nwHeaders:
|
||||
|
||||
class nwStyles:
|
||||
|
||||
H_VALID = ("H0", "H1", "H2", "H3", "H4")
|
||||
H_LEVEL = {"H0": 0, "H1": 1, "H2": 2, "H3": 3, "H4": 4}
|
||||
H_SIZES = {0: 2.50, 1: 2.00, 2: 1.75, 3: 1.50, 4: 1.25}
|
||||
|
||||
T_NORMAL = 1.0
|
||||
T_SMALL = 0.8
|
||||
|
||||
T_LABEL = {
|
||||
"H0": QT_TRANSLATE_NOOP("Constant", "Title"),
|
||||
"H1": QT_TRANSLATE_NOOP("Constant", "Heading 1 (Partition)"),
|
||||
"H2": QT_TRANSLATE_NOOP("Constant", "Heading 2 (Chapter)"),
|
||||
"H3": QT_TRANSLATE_NOOP("Constant", "Heading 3 (Scene)"),
|
||||
"H4": QT_TRANSLATE_NOOP("Constant", "Heading 4 (Section)"),
|
||||
"TT": QT_TRANSLATE_NOOP("Constant", "Text Paragraph"),
|
||||
"SP": QT_TRANSLATE_NOOP("Constant", "Scene Separator"),
|
||||
}
|
||||
T_MARGIN = {
|
||||
"H0": (1.50, 0.60), # Title margins (top, bottom)
|
||||
"H1": (1.50, 0.60), # Heading 1 margins (top, bottom)
|
||||
"H2": (1.50, 0.60), # Heading 2 margins (top, bottom)
|
||||
"H3": (1.20, 0.60), # Heading 3 margins (top, bottom)
|
||||
"H4": (1.20, 0.60), # Heading 4 margins (top, bottom)
|
||||
"TT": (0.00, 0.60), # Text margins (top, bottom)
|
||||
"SP": (1.20, 1.20), # Separator margins (top, bottom)
|
||||
"MT": (0.00, 0.60), # Meta margins (top, bottom)
|
||||
"FT": (1.40, 0.40), # Footnote margins (left, bottom)
|
||||
}
|
||||
|
||||
|
||||
class nwFiles:
|
||||
|
||||
@@ -121,34 +155,40 @@ class nwFiles:
|
||||
|
||||
class nwKeyWords:
|
||||
|
||||
TAG_KEY = "@tag"
|
||||
POV_KEY = "@pov"
|
||||
FOCUS_KEY = "@focus"
|
||||
CHAR_KEY = "@char"
|
||||
PLOT_KEY = "@plot"
|
||||
TIME_KEY = "@time"
|
||||
WORLD_KEY = "@location"
|
||||
OBJECT_KEY = "@object"
|
||||
ENTITY_KEY = "@entity"
|
||||
CUSTOM_KEY = "@custom"
|
||||
TAG_KEY = "@tag"
|
||||
POV_KEY = "@pov"
|
||||
FOCUS_KEY = "@focus"
|
||||
CHAR_KEY = "@char"
|
||||
PLOT_KEY = "@plot"
|
||||
TIME_KEY = "@time"
|
||||
WORLD_KEY = "@location"
|
||||
OBJECT_KEY = "@object"
|
||||
ENTITY_KEY = "@entity"
|
||||
CUSTOM_KEY = "@custom"
|
||||
STORY_KEY = "@story"
|
||||
MENTION_KEY = "@mention"
|
||||
|
||||
# Note: The order here affects the order of menu entries
|
||||
ALL_KEYS = [
|
||||
TAG_KEY, POV_KEY, FOCUS_KEY, CHAR_KEY, PLOT_KEY, TIME_KEY, WORLD_KEY,
|
||||
OBJECT_KEY, ENTITY_KEY, CUSTOM_KEY, STORY_KEY, MENTION_KEY,
|
||||
]
|
||||
|
||||
# Set of Valid Keys
|
||||
VALID_KEYS = {
|
||||
TAG_KEY, POV_KEY, FOCUS_KEY, CHAR_KEY, PLOT_KEY, TIME_KEY,
|
||||
WORLD_KEY, OBJECT_KEY, ENTITY_KEY, CUSTOM_KEY
|
||||
}
|
||||
VALID_KEYS = set(ALL_KEYS)
|
||||
|
||||
# Map from Keys to Item Class
|
||||
KEY_CLASS = {
|
||||
POV_KEY: nwItemClass.CHARACTER,
|
||||
FOCUS_KEY: nwItemClass.CHARACTER,
|
||||
CHAR_KEY: nwItemClass.CHARACTER,
|
||||
PLOT_KEY: nwItemClass.PLOT,
|
||||
TIME_KEY: nwItemClass.TIMELINE,
|
||||
WORLD_KEY: nwItemClass.WORLD,
|
||||
OBJECT_KEY: nwItemClass.OBJECT,
|
||||
ENTITY_KEY: nwItemClass.ENTITY,
|
||||
CUSTOM_KEY: nwItemClass.CUSTOM,
|
||||
POV_KEY: nwItemClass.CHARACTER,
|
||||
FOCUS_KEY: nwItemClass.CHARACTER,
|
||||
CHAR_KEY: nwItemClass.CHARACTER,
|
||||
PLOT_KEY: nwItemClass.PLOT,
|
||||
TIME_KEY: nwItemClass.TIMELINE,
|
||||
WORLD_KEY: nwItemClass.WORLD,
|
||||
OBJECT_KEY: nwItemClass.OBJECT,
|
||||
ENTITY_KEY: nwItemClass.ENTITY,
|
||||
CUSTOM_KEY: nwItemClass.CUSTOM,
|
||||
STORY_KEY: nwItemClass.NOVEL,
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +205,29 @@ class nwLists:
|
||||
]
|
||||
|
||||
|
||||
class nwStats:
|
||||
|
||||
CHARS = "allChars"
|
||||
CHARS_TEXT = "textChars"
|
||||
CHARS_TITLE = "titleChars"
|
||||
PARAGRAPHS = "paragraphCount"
|
||||
TITLES = "titleCount"
|
||||
WCHARS_ALL = "allWordChars"
|
||||
WCHARS_TEXT = "textWordChars"
|
||||
WCHARS_TITLE = "titleWordChars"
|
||||
WORDS = "allWords"
|
||||
WORDS_TEXT = "textWords"
|
||||
WORDS_TITLE = "titleWords"
|
||||
|
||||
# Note: The order here affects the order of menu entries
|
||||
ALL_FIELDS = [
|
||||
WORDS, WORDS_TEXT, WORDS_TITLE,
|
||||
CHARS, CHARS_TEXT, CHARS_TITLE,
|
||||
WCHARS_ALL, WCHARS_TEXT, WCHARS_TITLE,
|
||||
PARAGRAPHS, TITLES,
|
||||
]
|
||||
|
||||
|
||||
class nwLabels:
|
||||
|
||||
CLASS_NAME = {
|
||||
@@ -211,84 +274,124 @@ class nwLabels:
|
||||
"doc_h4": QT_TRANSLATE_NOOP("Constant", "Novel Section"),
|
||||
"note": QT_TRANSLATE_NOOP("Constant", "Project Note"),
|
||||
}
|
||||
ACTIVE_NAME = {
|
||||
"checked": QT_TRANSLATE_NOOP("Constant", "Active"),
|
||||
"unchecked": QT_TRANSLATE_NOOP("Constant", "Inactive"),
|
||||
}
|
||||
KEY_NAME = {
|
||||
nwKeyWords.TAG_KEY: QT_TRANSLATE_NOOP("Constant", "Tag"),
|
||||
nwKeyWords.POV_KEY: QT_TRANSLATE_NOOP("Constant", "Point of View"),
|
||||
nwKeyWords.FOCUS_KEY: QT_TRANSLATE_NOOP("Constant", "Focus"),
|
||||
nwKeyWords.CHAR_KEY: QT_TRANSLATE_NOOP("Constant", "Characters"),
|
||||
nwKeyWords.PLOT_KEY: QT_TRANSLATE_NOOP("Constant", "Plot"),
|
||||
nwKeyWords.TIME_KEY: QT_TRANSLATE_NOOP("Constant", "Timeline"),
|
||||
nwKeyWords.WORLD_KEY: QT_TRANSLATE_NOOP("Constant", "Locations"),
|
||||
nwKeyWords.OBJECT_KEY: QT_TRANSLATE_NOOP("Constant", "Objects"),
|
||||
nwKeyWords.ENTITY_KEY: QT_TRANSLATE_NOOP("Constant", "Entities"),
|
||||
nwKeyWords.CUSTOM_KEY: QT_TRANSLATE_NOOP("Constant", "Custom"),
|
||||
nwKeyWords.TAG_KEY: QT_TRANSLATE_NOOP("Constant", "Tag"),
|
||||
nwKeyWords.POV_KEY: QT_TRANSLATE_NOOP("Constant", "Point of View"),
|
||||
nwKeyWords.FOCUS_KEY: QT_TRANSLATE_NOOP("Constant", "Focus"),
|
||||
nwKeyWords.CHAR_KEY: QT_TRANSLATE_NOOP("Constant", "Characters"),
|
||||
nwKeyWords.PLOT_KEY: QT_TRANSLATE_NOOP("Constant", "Plot"),
|
||||
nwKeyWords.TIME_KEY: QT_TRANSLATE_NOOP("Constant", "Timeline"),
|
||||
nwKeyWords.WORLD_KEY: QT_TRANSLATE_NOOP("Constant", "Locations"),
|
||||
nwKeyWords.OBJECT_KEY: QT_TRANSLATE_NOOP("Constant", "Objects"),
|
||||
nwKeyWords.ENTITY_KEY: QT_TRANSLATE_NOOP("Constant", "Entities"),
|
||||
nwKeyWords.CUSTOM_KEY: QT_TRANSLATE_NOOP("Constant", "Custom"),
|
||||
nwKeyWords.STORY_KEY: QT_TRANSLATE_NOOP("Constant", "Story"),
|
||||
nwKeyWords.MENTION_KEY: QT_TRANSLATE_NOOP("Constant", "Mentions"),
|
||||
}
|
||||
KEY_SHORTCUT = {
|
||||
nwKeyWords.TAG_KEY: "Ctrl+K, G",
|
||||
nwKeyWords.POV_KEY: "Ctrl+K, V",
|
||||
nwKeyWords.FOCUS_KEY: "Ctrl+K, F",
|
||||
nwKeyWords.CHAR_KEY: "Ctrl+K, C",
|
||||
nwKeyWords.PLOT_KEY: "Ctrl+K, P",
|
||||
nwKeyWords.TIME_KEY: "Ctrl+K, T",
|
||||
nwKeyWords.WORLD_KEY: "Ctrl+K, L",
|
||||
nwKeyWords.OBJECT_KEY: "Ctrl+K, O",
|
||||
nwKeyWords.ENTITY_KEY: "Ctrl+K, E",
|
||||
nwKeyWords.CUSTOM_KEY: "Ctrl+K, X",
|
||||
nwKeyWords.STORY_KEY: "Ctrl+K, N",
|
||||
nwKeyWords.MENTION_KEY: "Ctrl+K, M",
|
||||
}
|
||||
OUTLINE_COLS = {
|
||||
nwOutline.TITLE: QT_TRANSLATE_NOOP("Constant", "Title"),
|
||||
nwOutline.LEVEL: QT_TRANSLATE_NOOP("Constant", "Level"),
|
||||
nwOutline.LABEL: QT_TRANSLATE_NOOP("Constant", "Document"),
|
||||
nwOutline.LINE: QT_TRANSLATE_NOOP("Constant", "Line"),
|
||||
nwOutline.CCOUNT: QT_TRANSLATE_NOOP("Constant", "Chars"),
|
||||
nwOutline.WCOUNT: QT_TRANSLATE_NOOP("Constant", "Words"),
|
||||
nwOutline.PCOUNT: QT_TRANSLATE_NOOP("Constant", "Pars"),
|
||||
nwOutline.POV: QT_TRANSLATE_NOOP("Constant", "POV"),
|
||||
nwOutline.FOCUS: QT_TRANSLATE_NOOP("Constant", "Focus"),
|
||||
nwOutline.CHAR: KEY_NAME[nwKeyWords.CHAR_KEY],
|
||||
nwOutline.PLOT: KEY_NAME[nwKeyWords.PLOT_KEY],
|
||||
nwOutline.WORLD: KEY_NAME[nwKeyWords.WORLD_KEY],
|
||||
nwOutline.TIME: KEY_NAME[nwKeyWords.TIME_KEY],
|
||||
nwOutline.OBJECT: KEY_NAME[nwKeyWords.OBJECT_KEY],
|
||||
nwOutline.ENTITY: KEY_NAME[nwKeyWords.ENTITY_KEY],
|
||||
nwOutline.CUSTOM: KEY_NAME[nwKeyWords.CUSTOM_KEY],
|
||||
nwOutline.SYNOP: QT_TRANSLATE_NOOP("Constant", "Synopsis"),
|
||||
nwOutline.TITLE: QT_TRANSLATE_NOOP("Constant", "Title"),
|
||||
nwOutline.LEVEL: QT_TRANSLATE_NOOP("Constant", "Level"),
|
||||
nwOutline.LABEL: QT_TRANSLATE_NOOP("Constant", "Document"),
|
||||
nwOutline.LINE: QT_TRANSLATE_NOOP("Constant", "Line"),
|
||||
nwOutline.STATUS: QT_TRANSLATE_NOOP("Constant", "Status"),
|
||||
nwOutline.CCOUNT: QT_TRANSLATE_NOOP("Constant", "Chars"),
|
||||
nwOutline.WCOUNT: QT_TRANSLATE_NOOP("Constant", "Words"),
|
||||
nwOutline.PCOUNT: QT_TRANSLATE_NOOP("Constant", "Pars"),
|
||||
nwOutline.POV: QT_TRANSLATE_NOOP("Constant", "POV"),
|
||||
nwOutline.FOCUS: QT_TRANSLATE_NOOP("Constant", "Focus"),
|
||||
nwOutline.CHAR: KEY_NAME[nwKeyWords.CHAR_KEY],
|
||||
nwOutline.PLOT: KEY_NAME[nwKeyWords.PLOT_KEY],
|
||||
nwOutline.WORLD: KEY_NAME[nwKeyWords.WORLD_KEY],
|
||||
nwOutline.TIME: KEY_NAME[nwKeyWords.TIME_KEY],
|
||||
nwOutline.OBJECT: KEY_NAME[nwKeyWords.OBJECT_KEY],
|
||||
nwOutline.ENTITY: KEY_NAME[nwKeyWords.ENTITY_KEY],
|
||||
nwOutline.CUSTOM: KEY_NAME[nwKeyWords.CUSTOM_KEY],
|
||||
nwOutline.STORY: KEY_NAME[nwKeyWords.STORY_KEY],
|
||||
nwOutline.MENTION: KEY_NAME[nwKeyWords.MENTION_KEY],
|
||||
nwOutline.SYNOP: QT_TRANSLATE_NOOP("Constant", "Synopsis"),
|
||||
}
|
||||
STATS_NAME = {
|
||||
nwStats.CHARS: QT_TRANSLATE_NOOP("Stats", "Characters"),
|
||||
nwStats.CHARS_TEXT: QT_TRANSLATE_NOOP("Stats", "Characters in Text"),
|
||||
nwStats.CHARS_TITLE: QT_TRANSLATE_NOOP("Stats", "Characters in Headings"),
|
||||
nwStats.PARAGRAPHS: QT_TRANSLATE_NOOP("Stats", "Paragraphs"),
|
||||
nwStats.TITLES: QT_TRANSLATE_NOOP("Stats", "Headings"),
|
||||
nwStats.WCHARS_ALL: QT_TRANSLATE_NOOP("Stats", "Characters, No Spaces"),
|
||||
nwStats.WCHARS_TEXT: QT_TRANSLATE_NOOP("Stats", "Characters in Text, No Spaces"),
|
||||
nwStats.WCHARS_TITLE: QT_TRANSLATE_NOOP("Stats", "Characters in Headings, No Spaces"),
|
||||
nwStats.WORDS: QT_TRANSLATE_NOOP("Stats", "Words"),
|
||||
nwStats.WORDS_TEXT: QT_TRANSLATE_NOOP("Stats", "Words in Text"),
|
||||
nwStats.WORDS_TITLE: QT_TRANSLATE_NOOP("Stats", "Words in Headings"),
|
||||
}
|
||||
BUILD_FMT = {
|
||||
nwBuildFmt.ODT: QT_TRANSLATE_NOOP("Constant", "Open Document (.odt)"),
|
||||
nwBuildFmt.FODT: QT_TRANSLATE_NOOP("Constant", "Flat Open Document (.fodt)"),
|
||||
nwBuildFmt.HTML: QT_TRANSLATE_NOOP("Constant", "novelWriter HTML (.html)"),
|
||||
nwBuildFmt.DOCX: QT_TRANSLATE_NOOP("Constant", "Microsoft Word Document (.docx)"),
|
||||
nwBuildFmt.HTML: QT_TRANSLATE_NOOP("Constant", "HTML 5 (.html)"),
|
||||
nwBuildFmt.NWD: QT_TRANSLATE_NOOP("Constant", "novelWriter Markup (.txt)"),
|
||||
nwBuildFmt.STD_MD: QT_TRANSLATE_NOOP("Constant", "Standard Markdown (.md)"),
|
||||
nwBuildFmt.EXT_MD: QT_TRANSLATE_NOOP("Constant", "Extended Markdown (.md)"),
|
||||
nwBuildFmt.J_HTML: QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter HTML (.json)"),
|
||||
nwBuildFmt.PDF: QT_TRANSLATE_NOOP("Constant", "Portable Document Format (.pdf)"),
|
||||
nwBuildFmt.J_HTML: QT_TRANSLATE_NOOP("Constant", "JSON + HTML 5 (.json)"),
|
||||
nwBuildFmt.J_NWD: QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter Markup (.json)"),
|
||||
}
|
||||
BUILD_EXT = {
|
||||
nwBuildFmt.ODT: ".odt",
|
||||
nwBuildFmt.FODT: ".fodt",
|
||||
nwBuildFmt.DOCX: ".docx",
|
||||
nwBuildFmt.HTML: ".html",
|
||||
nwBuildFmt.NWD: ".txt",
|
||||
nwBuildFmt.STD_MD: ".md",
|
||||
nwBuildFmt.EXT_MD: ".md",
|
||||
nwBuildFmt.PDF: ".pdf",
|
||||
nwBuildFmt.J_HTML: ".json",
|
||||
nwBuildFmt.J_NWD: ".json",
|
||||
}
|
||||
SHAPES_PLAIN = {
|
||||
nwStatusShape.SQUARE: QT_TRANSLATE_NOOP("Constant", "Square"),
|
||||
nwStatusShape.TRIANGLE: QT_TRANSLATE_NOOP("Constant", "Triangle"),
|
||||
nwStatusShape.NABLA: QT_TRANSLATE_NOOP("Constant", "Nabla"),
|
||||
nwStatusShape.DIAMOND: QT_TRANSLATE_NOOP("Constant", "Diamond"),
|
||||
nwStatusShape.PENTAGON: QT_TRANSLATE_NOOP("Constant", "Pentagon"),
|
||||
nwStatusShape.HEXAGON: QT_TRANSLATE_NOOP("Constant", "Hexagon"),
|
||||
nwStatusShape.STAR: QT_TRANSLATE_NOOP("Constant", "Star"),
|
||||
nwStatusShape.PACMAN: QT_TRANSLATE_NOOP("Constant", "Pacman"),
|
||||
nwStatusShape.SQUARE: QT_TRANSLATE_NOOP("Shape", "Square"),
|
||||
nwStatusShape.TRIANGLE: QT_TRANSLATE_NOOP("Shape", "Triangle"),
|
||||
nwStatusShape.NABLA: QT_TRANSLATE_NOOP("Shape", "Nabla"),
|
||||
nwStatusShape.DIAMOND: QT_TRANSLATE_NOOP("Shape", "Diamond"),
|
||||
nwStatusShape.PENTAGON: QT_TRANSLATE_NOOP("Shape", "Pentagon"),
|
||||
nwStatusShape.HEXAGON: QT_TRANSLATE_NOOP("Shape", "Hexagon"),
|
||||
nwStatusShape.STAR: QT_TRANSLATE_NOOP("Shape", "Star"),
|
||||
nwStatusShape.PACMAN: QT_TRANSLATE_NOOP("Shape", "Pacman"),
|
||||
}
|
||||
SHAPES_CIRCLE = {
|
||||
nwStatusShape.CIRCLE_Q: QT_TRANSLATE_NOOP("Constant", "1/4 Circle"),
|
||||
nwStatusShape.CIRCLE_H: QT_TRANSLATE_NOOP("Constant", "Half Circle"),
|
||||
nwStatusShape.CIRCLE_T: QT_TRANSLATE_NOOP("Constant", "3/4 Circle"),
|
||||
nwStatusShape.CIRCLE: QT_TRANSLATE_NOOP("Constant", "Full Circle"),
|
||||
nwStatusShape.CIRCLE_Q: QT_TRANSLATE_NOOP("Shape", "1/4 Circle"),
|
||||
nwStatusShape.CIRCLE_H: QT_TRANSLATE_NOOP("Shape", "Half Circle"),
|
||||
nwStatusShape.CIRCLE_T: QT_TRANSLATE_NOOP("Shape", "3/4 Circle"),
|
||||
nwStatusShape.CIRCLE: QT_TRANSLATE_NOOP("Shape", "Full Circle"),
|
||||
}
|
||||
SHAPES_BARS = {
|
||||
nwStatusShape.BARS_1: QT_TRANSLATE_NOOP("Constant", "1 Bar"),
|
||||
nwStatusShape.BARS_2: QT_TRANSLATE_NOOP("Constant", "2 Bars"),
|
||||
nwStatusShape.BARS_3: QT_TRANSLATE_NOOP("Constant", "3 Bars"),
|
||||
nwStatusShape.BARS_4: QT_TRANSLATE_NOOP("Constant", "4 Bars"),
|
||||
nwStatusShape.BARS_1: QT_TRANSLATE_NOOP("Shape", "1 Bar"),
|
||||
nwStatusShape.BARS_2: QT_TRANSLATE_NOOP("Shape", "2 Bars"),
|
||||
nwStatusShape.BARS_3: QT_TRANSLATE_NOOP("Shape", "3 Bars"),
|
||||
nwStatusShape.BARS_4: QT_TRANSLATE_NOOP("Shape", "4 Bars"),
|
||||
}
|
||||
SHAPES_BLOCKS = {
|
||||
nwStatusShape.BLOCK_1: QT_TRANSLATE_NOOP("Constant", "1 Block"),
|
||||
nwStatusShape.BLOCK_2: QT_TRANSLATE_NOOP("Constant", "2 Blocks"),
|
||||
nwStatusShape.BLOCK_3: QT_TRANSLATE_NOOP("Constant", "3 Blocks"),
|
||||
nwStatusShape.BLOCK_4: QT_TRANSLATE_NOOP("Constant", "4 Blocks"),
|
||||
nwStatusShape.BLOCK_1: QT_TRANSLATE_NOOP("Shape", "1 Block"),
|
||||
nwStatusShape.BLOCK_2: QT_TRANSLATE_NOOP("Shape", "2 Blocks"),
|
||||
nwStatusShape.BLOCK_3: QT_TRANSLATE_NOOP("Shape", "3 Blocks"),
|
||||
nwStatusShape.BLOCK_4: QT_TRANSLATE_NOOP("Shape", "4 Blocks"),
|
||||
}
|
||||
FILE_FILTERS = {
|
||||
"*.txt": QT_TRANSLATE_NOOP("Constant", "Text files"),
|
||||
@@ -343,11 +446,11 @@ class nwHeadFmt:
|
||||
CHAR_POV, CHAR_FOCUS
|
||||
]
|
||||
|
||||
# ODT Document Page Header
|
||||
ODT_PROJECT = "{Project}"
|
||||
ODT_AUTHOR = "{Author}"
|
||||
ODT_PAGE = "{Page}"
|
||||
ODT_AUTO = "{Project} / {Author} / {Page}"
|
||||
# Document Page Header
|
||||
DOC_PROJECT = "{Project}"
|
||||
DOC_AUTHOR = "{Author}"
|
||||
DOC_PAGE = "{Page}"
|
||||
DOC_AUTO = "{Project} / {Author} / {Page}"
|
||||
|
||||
|
||||
class nwQuotes:
|
||||
@@ -449,6 +552,11 @@ class nwUnicode:
|
||||
U_LTRI = "\u25c0" # Left-pointing triangle
|
||||
U_LTRIS = "\u25c2" # Left-pointing triangle, small
|
||||
|
||||
# Special
|
||||
U_UNKN = "\ufffd" # Unknown character
|
||||
U_NAC1 = "\ufffe" # Not a character
|
||||
U_NAC2 = "\uffff" # Not a character
|
||||
|
||||
# HTML Equivalents
|
||||
# ================
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Created: 2023-02-14 [2.1b1] BuildSettings
|
||||
Created: 2023-05-22 [2.1b1] BuildCollection
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2023 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -36,7 +36,7 @@ from PyQt5.QtCore import QT_TRANSLATE_NOOP, QCoreApplication
|
||||
|
||||
from novelwriter import CONFIG
|
||||
from novelwriter.common import checkUuid, isHandle, jsonEncode
|
||||
from novelwriter.constants import nwFiles, nwHeadFmt
|
||||
from novelwriter.constants import nwFiles, nwHeadFmt, nwStyles
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.enum import nwBuildFmt
|
||||
from novelwriter.error import logException
|
||||
@@ -45,29 +45,30 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# The Settings Template
|
||||
# =====================
|
||||
# Each entry contains a tuple on the form:
|
||||
# (type, default, [min value, max value])
|
||||
# Each entry contains a tuple on the form: (type, default)
|
||||
|
||||
SETTINGS_TEMPLATE = {
|
||||
SETTINGS_TEMPLATE: dict[str, tuple[type, str | int | float | bool]] = {
|
||||
"filter.includeNovel": (bool, True),
|
||||
"filter.includeNotes": (bool, False),
|
||||
"filter.includeInactive": (bool, False),
|
||||
"headings.fmtTitle": (str, nwHeadFmt.TITLE),
|
||||
"headings.fmtPart": (str, nwHeadFmt.TITLE),
|
||||
"headings.fmtChapter": (str, nwHeadFmt.TITLE),
|
||||
"headings.fmtUnnumbered": (str, nwHeadFmt.TITLE),
|
||||
"headings.fmtScene": (str, "* * *"),
|
||||
"headings.fmtAltScene": (str, ""),
|
||||
"headings.fmtSection": (str, ""),
|
||||
"headings.hideTitle": (bool, False),
|
||||
"headings.hidePart": (bool, False),
|
||||
"headings.hideChapter": (bool, False),
|
||||
"headings.hideUnnumbered": (bool, False),
|
||||
"headings.hideScene": (bool, False),
|
||||
"headings.hideAltScene": (bool, False),
|
||||
"headings.hideSection": (bool, True),
|
||||
"headings.centerTitle": (bool, True),
|
||||
"headings.centerPart": (bool, True),
|
||||
"headings.centerChapter": (bool, False),
|
||||
"headings.centerScene": (bool, False),
|
||||
"headings.breakTitle": (bool, True),
|
||||
"headings.breakTitle": (bool, False),
|
||||
"headings.breakPart": (bool, True),
|
||||
"headings.breakChapter": (bool, True),
|
||||
"headings.breakScene": (bool, False),
|
||||
"text.includeSynopsis": (bool, False),
|
||||
@@ -77,7 +78,7 @@ SETTINGS_TEMPLATE = {
|
||||
"text.ignoredKeywords": (str, ""),
|
||||
"text.addNoteHeadings": (bool, True),
|
||||
"format.textFont": (str, CONFIG.textFont.toString()),
|
||||
"format.lineHeight": (float, 1.15, 0.75, 3.0),
|
||||
"format.lineHeight": (float, 1.15),
|
||||
"format.justifyText": (bool, False),
|
||||
"format.stripUnicode": (bool, False),
|
||||
"format.replaceTabs": (bool, False),
|
||||
@@ -86,6 +87,20 @@ SETTINGS_TEMPLATE = {
|
||||
"format.firstLineIndent": (bool, False),
|
||||
"format.firstIndentWidth": (float, 1.4),
|
||||
"format.indentFirstPar": (bool, False),
|
||||
"format.titleMarginT": (float, nwStyles.T_MARGIN["H0"][0]),
|
||||
"format.titleMarginB": (float, nwStyles.T_MARGIN["H0"][1]),
|
||||
"format.h1MarginT": (float, nwStyles.T_MARGIN["H1"][0]),
|
||||
"format.h1MarginB": (float, nwStyles.T_MARGIN["H1"][1]),
|
||||
"format.h2MarginT": (float, nwStyles.T_MARGIN["H2"][0]),
|
||||
"format.h2MarginB": (float, nwStyles.T_MARGIN["H2"][1]),
|
||||
"format.h3MarginT": (float, nwStyles.T_MARGIN["H3"][0]),
|
||||
"format.h3MarginB": (float, nwStyles.T_MARGIN["H3"][1]),
|
||||
"format.h4MarginT": (float, nwStyles.T_MARGIN["H4"][0]),
|
||||
"format.h4MarginB": (float, nwStyles.T_MARGIN["H4"][1]),
|
||||
"format.textMarginT": (float, nwStyles.T_MARGIN["TT"][0]),
|
||||
"format.textMarginB": (float, nwStyles.T_MARGIN["TT"][1]),
|
||||
"format.sepMarginT": (float, nwStyles.T_MARGIN["SP"][0]),
|
||||
"format.sepMarginB": (float, nwStyles.T_MARGIN["SP"][1]),
|
||||
"format.pageUnit": (str, "cm"),
|
||||
"format.pageSize": (str, "A4"),
|
||||
"format.pageWidth": (float, 21.0),
|
||||
@@ -94,9 +109,11 @@ SETTINGS_TEMPLATE = {
|
||||
"format.bottomMargin": (float, 2.0),
|
||||
"format.leftMargin": (float, 2.0),
|
||||
"format.rightMargin": (float, 2.0),
|
||||
"odt.addColours": (bool, True),
|
||||
"odt.pageHeader": (str, nwHeadFmt.ODT_AUTO),
|
||||
"odt.pageCountOffset": (int, 0),
|
||||
"doc.pageHeader": (str, nwHeadFmt.DOC_AUTO),
|
||||
"doc.pageCountOffset": (int, 0),
|
||||
"doc.colorHeadings": (bool, True),
|
||||
"doc.scaleHeadings": (bool, True),
|
||||
"doc.boldHeadings": (bool, True),
|
||||
"html.addStyles": (bool, True),
|
||||
"html.preserveTabs": (bool, False),
|
||||
}
|
||||
@@ -108,12 +125,16 @@ SETTINGS_LABELS = {
|
||||
"filter.includeInactive": QT_TRANSLATE_NOOP("Builds", "Inactive Documents"),
|
||||
|
||||
"headings": QT_TRANSLATE_NOOP("Builds", "Headings"),
|
||||
"headings.fmtTitle": QT_TRANSLATE_NOOP("Builds", "Partition Format"),
|
||||
"headings.fmtPart": QT_TRANSLATE_NOOP("Builds", "Partition Format"),
|
||||
"headings.fmtChapter": QT_TRANSLATE_NOOP("Builds", "Chapter Format"),
|
||||
"headings.fmtUnnumbered": QT_TRANSLATE_NOOP("Builds", "Unnumbered Format"),
|
||||
"headings.fmtScene": QT_TRANSLATE_NOOP("Builds", "Scene Format"),
|
||||
"headings.fmtAltScene": QT_TRANSLATE_NOOP("Builds", "Alt. Scene Format"),
|
||||
"headings.fmtSection": QT_TRANSLATE_NOOP("Builds", "Section Format"),
|
||||
"headings.styleTitle": QT_TRANSLATE_NOOP("Builds", "Title Styling"),
|
||||
"headings.stylePart": QT_TRANSLATE_NOOP("Builds", "Partition Styling"),
|
||||
"headings.styleChapter": QT_TRANSLATE_NOOP("Builds", "Chapter Styling"),
|
||||
"headings.styleScene": QT_TRANSLATE_NOOP("Builds", "Scene Styling"),
|
||||
|
||||
"text.grpContent": QT_TRANSLATE_NOOP("Builds", "Text Content"),
|
||||
"text.includeSynopsis": QT_TRANSLATE_NOOP("Builds", "Include Synopsis"),
|
||||
@@ -121,13 +142,11 @@ SETTINGS_LABELS = {
|
||||
"text.includeKeywords": QT_TRANSLATE_NOOP("Builds", "Include Keywords"),
|
||||
"text.includeBodyText": QT_TRANSLATE_NOOP("Builds", "Include Body Text"),
|
||||
"text.ignoredKeywords": QT_TRANSLATE_NOOP("Builds", "Ignore These Keywords"),
|
||||
"text.grpInsert": QT_TRANSLATE_NOOP("Builds", "Insert Content"),
|
||||
"text.addNoteHeadings": QT_TRANSLATE_NOOP("Builds", "Add Titles for Notes"),
|
||||
|
||||
"format.grpFormat": QT_TRANSLATE_NOOP("Builds", "Text Format"),
|
||||
"format.textFont": QT_TRANSLATE_NOOP("Builds", "Text Font"),
|
||||
"format.lineHeight": QT_TRANSLATE_NOOP("Builds", "Line Height"),
|
||||
"format.grpOptions": QT_TRANSLATE_NOOP("Builds", "Text Options"),
|
||||
"format.justifyText": QT_TRANSLATE_NOOP("Builds", "Justify Text Margins"),
|
||||
"format.stripUnicode": QT_TRANSLATE_NOOP("Builds", "Replace Unicode Characters"),
|
||||
"format.replaceTabs": QT_TRANSLATE_NOOP("Builds", "Replace Tabs with Spaces"),
|
||||
@@ -139,26 +158,38 @@ SETTINGS_LABELS = {
|
||||
"format.firstIndentWidth": QT_TRANSLATE_NOOP("Builds", "Indent Width"),
|
||||
"format.indentFirstPar": QT_TRANSLATE_NOOP("Builds", "Indent First Paragraph"),
|
||||
|
||||
"format.grpMargins": QT_TRANSLATE_NOOP("Builds", "Text Margins"),
|
||||
"format.titleMargin": QT_TRANSLATE_NOOP("Builds", "Title and Partition"),
|
||||
"format.h1Margin": QT_TRANSLATE_NOOP("Builds", "Heading 1 and Chapter"),
|
||||
"format.h2Margin": QT_TRANSLATE_NOOP("Builds", "Heading 2 and Scene"),
|
||||
"format.h3Margin": QT_TRANSLATE_NOOP("Builds", "Heading 3 and Section"),
|
||||
"format.h4Margin": QT_TRANSLATE_NOOP("Builds", "Heading 4"),
|
||||
"format.textMargin": QT_TRANSLATE_NOOP("Builds", "Text Paragraph"),
|
||||
"format.sepMargin": QT_TRANSLATE_NOOP("Builds", "Scene Separator"),
|
||||
|
||||
"format.grpPage": QT_TRANSLATE_NOOP("Builds", "Page Layout"),
|
||||
"format.pageUnit": QT_TRANSLATE_NOOP("Builds", "Unit"),
|
||||
"format.pageSize": QT_TRANSLATE_NOOP("Builds", "Page Size"),
|
||||
"format.pageWidth": QT_TRANSLATE_NOOP("Builds", "Page Width"),
|
||||
"format.pageHeight": QT_TRANSLATE_NOOP("Builds", "Page Height"),
|
||||
"format.topMargin": QT_TRANSLATE_NOOP("Builds", "Top Margin"),
|
||||
"format.bottomMargin": QT_TRANSLATE_NOOP("Builds", "Bottom Margin"),
|
||||
"format.leftMargin": QT_TRANSLATE_NOOP("Builds", "Left Margin"),
|
||||
"format.rightMargin": QT_TRANSLATE_NOOP("Builds", "Right Margin"),
|
||||
"format.pageMargins": QT_TRANSLATE_NOOP("Builds", "Page Margins"),
|
||||
|
||||
"odt": QT_TRANSLATE_NOOP("Builds", "Open Document (.odt)"),
|
||||
"odt.addColours": QT_TRANSLATE_NOOP("Builds", "Add Highlight Colours"),
|
||||
"odt.pageHeader": QT_TRANSLATE_NOOP("Builds", "Page Header"),
|
||||
"odt.pageCountOffset": QT_TRANSLATE_NOOP("Builds", "Page Counter Offset"),
|
||||
"doc": QT_TRANSLATE_NOOP("Builds", "Document Style"),
|
||||
"doc.pageHeader": QT_TRANSLATE_NOOP("Builds", "Page Header"),
|
||||
"doc.pageCountOffset": QT_TRANSLATE_NOOP("Builds", "Page Counter Offset"),
|
||||
"doc.colorHeadings": QT_TRANSLATE_NOOP("Builds", "Add Colours to Headings"),
|
||||
"doc.scaleHeadings": QT_TRANSLATE_NOOP("Builds", "Increase Size of Headings"),
|
||||
"doc.boldHeadings": QT_TRANSLATE_NOOP("Builds", "Bold Headings"),
|
||||
|
||||
"html": QT_TRANSLATE_NOOP("Builds", "HTML (.html)"),
|
||||
"html": QT_TRANSLATE_NOOP("Builds", "HTML Options"),
|
||||
"html.addStyles": QT_TRANSLATE_NOOP("Builds", "Add CSS Styles"),
|
||||
"html.preserveTabs": QT_TRANSLATE_NOOP("Builds", "Preserve Tab Characters"),
|
||||
}
|
||||
|
||||
RENAMED = {
|
||||
"odt.addColours": "doc.addColours",
|
||||
"odt.pageHeader": "doc.pageHeader",
|
||||
"odt.pageCountOffset": "doc.pageCountOffset",
|
||||
}
|
||||
|
||||
|
||||
class FilterMode(Enum):
|
||||
"""The decision reason for an item in a filtered project."""
|
||||
@@ -348,18 +379,12 @@ class BuildSettings:
|
||||
self._changed = True
|
||||
return
|
||||
|
||||
def setValue(self, key: str, value: str | int | bool | float) -> bool:
|
||||
def setValue(self, key: str, value: str | int | float | bool) -> None:
|
||||
"""Set a specific value for a build setting."""
|
||||
if key not in SETTINGS_TEMPLATE:
|
||||
return False
|
||||
definition = SETTINGS_TEMPLATE[key]
|
||||
if not isinstance(value, definition[0]):
|
||||
return False
|
||||
if len(definition) == 4 and isinstance(value, (int, float)):
|
||||
value = min(max(value, definition[2]), definition[3])
|
||||
self._changed = value != self._settings[key]
|
||||
self._settings[key] = value
|
||||
return True
|
||||
if (d := SETTINGS_TEMPLATE.get(key)) and len(d) == 2 and isinstance(value, d[0]):
|
||||
self._changed = value != self._settings[key]
|
||||
self._settings[key] = value
|
||||
return
|
||||
|
||||
##
|
||||
# Methods
|
||||
@@ -478,12 +503,21 @@ class BuildSettings:
|
||||
self._settings = {k: v[1] for k, v in SETTINGS_TEMPLATE.items()}
|
||||
if isinstance(settings, dict):
|
||||
for key, value in settings.items():
|
||||
self.setValue(key, value)
|
||||
self.setValue(RENAMED.get(key, key), value)
|
||||
|
||||
self._changed = False
|
||||
|
||||
return
|
||||
|
||||
@classmethod
|
||||
def duplicate(cls, source: BuildSettings) -> BuildSettings:
|
||||
"""Make a copy of another build."""
|
||||
cls = BuildSettings()
|
||||
cls.unpack(source.pack())
|
||||
cls._uuid = str(uuid.uuid4())
|
||||
cls._name = f"{source.name} 2"
|
||||
return cls
|
||||
|
||||
|
||||
class BuildCollection:
|
||||
"""Core: Build Collection Class
|
||||
|
||||
@@ -9,7 +9,7 @@ Created: 2022-11-03 [2.0rc2] ProjectBuilder
|
||||
Created: 2023-07-20 [2.1b1] DocDuplicator
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2022 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -27,6 +27,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from collections.abc import Iterable
|
||||
@@ -34,11 +35,11 @@ from functools import partial
|
||||
from pathlib import Path
|
||||
from zipfile import ZipFile, is_zipfile
|
||||
|
||||
from PyQt5.QtCore import QCoreApplication, QRegularExpression
|
||||
from PyQt5.QtCore import QCoreApplication
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.common import isHandle, minmax, simplified
|
||||
from novelwriter.constants import nwConst, nwFiles, nwItemClass
|
||||
from novelwriter.constants import nwConst, nwFiles, nwItemClass, nwStats
|
||||
from novelwriter.core.item import NWItem
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.core.storage import NWStorageCreate
|
||||
@@ -55,10 +56,17 @@ class DocMerger:
|
||||
def __init__(self, project: NWProject) -> None:
|
||||
self._project = project
|
||||
self._error = ""
|
||||
self._targetDoc = None
|
||||
self._targetText = []
|
||||
self._target = None
|
||||
self._text = []
|
||||
return
|
||||
|
||||
@property
|
||||
def targetHandle(self) -> str | None:
|
||||
"""Get the handle of the target document."""
|
||||
if self._target:
|
||||
return self._target.itemHandle
|
||||
return None
|
||||
|
||||
##
|
||||
# Methods
|
||||
##
|
||||
@@ -71,63 +79,56 @@ class DocMerger:
|
||||
"""Set the target document for the merging. Calling this
|
||||
function resets the class.
|
||||
"""
|
||||
self._targetDoc = tHandle
|
||||
self._targetText = []
|
||||
self._target = self._project.tree[tHandle]
|
||||
self._text = []
|
||||
return
|
||||
|
||||
def newTargetDoc(self, srcHandle: str, docLabel: str) -> str | None:
|
||||
def newTargetDoc(self, sHandle: str, label: str) -> None:
|
||||
"""Create a brand new target document based on a source handle
|
||||
and a new doc label. Calling this function resets the class.
|
||||
"""
|
||||
srcItem = self._project.tree[srcHandle]
|
||||
if srcItem is None or srcItem.itemParent is None:
|
||||
return None
|
||||
sItem = self._project.tree[sHandle]
|
||||
if sItem and sItem.itemParent:
|
||||
tHandle = self._project.newFile(label, sItem.itemParent)
|
||||
if nwItem := self._project.tree[tHandle]:
|
||||
nwItem.setLayout(sItem.itemLayout)
|
||||
nwItem.setStatus(sItem.itemStatus)
|
||||
nwItem.setImport(sItem.itemImport)
|
||||
nwItem.notifyToRefresh()
|
||||
self._target = nwItem
|
||||
self._text = []
|
||||
return
|
||||
|
||||
newHandle = self._project.newFile(docLabel, srcItem.itemParent)
|
||||
newItem = self._project.tree[newHandle]
|
||||
if isinstance(newItem, NWItem):
|
||||
newItem.setLayout(srcItem.itemLayout)
|
||||
newItem.setStatus(srcItem.itemStatus)
|
||||
newItem.setImport(srcItem.itemImport)
|
||||
|
||||
self._targetDoc = newHandle
|
||||
self._targetText = []
|
||||
|
||||
return newHandle
|
||||
|
||||
def appendText(self, srcHandle: str, addComment: bool, cmtPrefix: str) -> bool:
|
||||
def appendText(self, sHandle: str, addComment: bool, cmtPrefix: str) -> None:
|
||||
"""Append text from an existing document to the text buffer."""
|
||||
srcItem = self._project.tree[srcHandle]
|
||||
if srcItem is None:
|
||||
return False
|
||||
|
||||
docText = self._project.storage.getDocumentText(srcHandle).rstrip("\n")
|
||||
if addComment:
|
||||
docInfo = srcItem.describeMe()
|
||||
docSt, _ = srcItem.getImportStatus()
|
||||
cmtLine = f"% {cmtPrefix} {docInfo}: {srcItem.itemName} [{docSt}]\n\n"
|
||||
docText = cmtLine + docText
|
||||
|
||||
self._targetText.append(docText)
|
||||
|
||||
return True
|
||||
if item := self._project.tree[sHandle]:
|
||||
text = self._project.storage.getDocumentText(sHandle).rstrip("\n")
|
||||
if addComment:
|
||||
info = item.describeMe()
|
||||
status, _ = item.getImportStatus()
|
||||
text = f"% {cmtPrefix} {info}: {item.itemName} [{status}]\n\n{text}"
|
||||
self._text.append(text)
|
||||
return
|
||||
|
||||
def writeTargetDoc(self) -> bool:
|
||||
"""Write the accumulated text into the designated target
|
||||
document, appending any existing text.
|
||||
"""
|
||||
if self._targetDoc is None:
|
||||
return False
|
||||
if self._target:
|
||||
outDoc = self._project.storage.getDocument(self._target.itemHandle)
|
||||
if text := (outDoc.readDocument() or "").rstrip("\n"):
|
||||
self._text.insert(0, text)
|
||||
|
||||
outDoc = self._project.storage.getDocument(self._targetDoc)
|
||||
if text := (outDoc.readDocument() or "").rstrip("\n"):
|
||||
self._targetText.insert(0, text)
|
||||
status = outDoc.writeDocument("\n\n".join(self._text) + "\n\n")
|
||||
if not status:
|
||||
self._error = outDoc.getError()
|
||||
|
||||
status = outDoc.writeDocument("\n\n".join(self._targetText) + "\n\n")
|
||||
if not status:
|
||||
self._error = outDoc.getError()
|
||||
self._project.index.reIndexHandle(self._target.itemHandle)
|
||||
self._target.notifyToRefresh()
|
||||
|
||||
return status
|
||||
return status
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class DocSplitter:
|
||||
@@ -171,23 +172,19 @@ class DocSplitter:
|
||||
self._inFolder = False
|
||||
return
|
||||
|
||||
def newParentFolder(self, pHandle: str, folderLabel: str) -> str | None:
|
||||
def newParentFolder(self, pHandle: str, folderLabel: str) -> None:
|
||||
"""Create a new folder that will be the top level parent item
|
||||
for the new documents.
|
||||
"""
|
||||
if self._srcItem is None:
|
||||
return None
|
||||
|
||||
newHandle = self._project.newFolder(folderLabel, pHandle)
|
||||
newItem = self._project.tree[newHandle]
|
||||
if isinstance(newItem, NWItem):
|
||||
newItem.setStatus(self._srcItem.itemStatus)
|
||||
newItem.setImport(self._srcItem.itemImport)
|
||||
|
||||
self._parHandle = newHandle
|
||||
self._inFolder = True
|
||||
|
||||
return newHandle
|
||||
if self._srcItem:
|
||||
nHandle = self._project.newFolder(folderLabel, pHandle)
|
||||
if nwItem := self._project.tree[nHandle]:
|
||||
nwItem.setStatus(self._srcItem.itemStatus)
|
||||
nwItem.setImport(self._srcItem.itemImport)
|
||||
nwItem.notifyToRefresh()
|
||||
self._parHandle = nHandle
|
||||
self._inFolder = True
|
||||
return
|
||||
|
||||
def splitDocument(self, splitData: list, splitText: list[str]) -> None:
|
||||
"""Loop through the split data record and perform the split job
|
||||
@@ -201,58 +198,50 @@ class DocSplitter:
|
||||
self._rawData.insert(0, (chunk, hLevel, hLabel))
|
||||
return
|
||||
|
||||
def writeDocuments(self, docHierarchy: bool) -> Iterable[tuple[bool, str | None, str | None]]:
|
||||
def writeDocuments(self, docHierarchy: bool) -> Iterable[bool]:
|
||||
"""An iterator that will write each document in the buffer, and
|
||||
return its new handle, parent handle, and sibling handle.
|
||||
"""
|
||||
if self._srcHandle is None or self._srcItem is None or self._parHandle is None:
|
||||
return
|
||||
if self._srcHandle and self._srcItem and self._parHandle:
|
||||
pHandle = self._parHandle
|
||||
hHandle = [self._parHandle, None, None, None, None]
|
||||
pLevel = 0
|
||||
for docText, hLevel, docLabel in self._rawData:
|
||||
|
||||
pHandle = self._parHandle
|
||||
nHandle = self._parHandle if self._inFolder else self._srcHandle
|
||||
hHandle = [self._parHandle, None, None, None, None]
|
||||
hLevel = minmax(hLevel, 1, 4)
|
||||
if pLevel == 0:
|
||||
pLevel = hLevel
|
||||
|
||||
pLevel = 0
|
||||
for docText, hLevel, docLabel in self._rawData:
|
||||
if docHierarchy:
|
||||
if hLevel == 1:
|
||||
pHandle = self._parHandle
|
||||
elif hLevel == 2:
|
||||
pHandle = hHandle[1] or hHandle[0]
|
||||
elif hLevel == 3:
|
||||
pHandle = hHandle[2] or hHandle[1] or hHandle[0]
|
||||
elif hLevel == 4:
|
||||
pHandle = hHandle[3] or hHandle[2] or hHandle[1] or hHandle[0]
|
||||
|
||||
hLevel = minmax(hLevel, 1, 4)
|
||||
if pLevel == 0:
|
||||
pLevel = hLevel
|
||||
if (
|
||||
(dHandle := self._project.newFile(docLabel, pHandle))
|
||||
and (nwItem := self._project.tree[dHandle])
|
||||
):
|
||||
hHandle[hLevel] = dHandle
|
||||
nwItem.setStatus(self._srcItem.itemStatus)
|
||||
nwItem.setImport(self._srcItem.itemImport)
|
||||
|
||||
if docHierarchy:
|
||||
if hLevel == 1:
|
||||
pHandle = self._parHandle
|
||||
elif hLevel == 2:
|
||||
pHandle = hHandle[1] or hHandle[0]
|
||||
elif hLevel == 3:
|
||||
pHandle = hHandle[2] or hHandle[1] or hHandle[0]
|
||||
elif hLevel == 4:
|
||||
pHandle = hHandle[3] or hHandle[2] or hHandle[1] or hHandle[0]
|
||||
outDoc = self._project.storage.getDocument(dHandle)
|
||||
status = outDoc.writeDocument("\n".join(docText))
|
||||
if not status:
|
||||
self._error = outDoc.getError()
|
||||
|
||||
if hLevel < pLevel:
|
||||
nHandle = hHandle[hLevel] or hHandle[0]
|
||||
elif hLevel > pLevel:
|
||||
nHandle = pHandle
|
||||
self._project.index.reIndexHandle(dHandle)
|
||||
nwItem.notifyToRefresh()
|
||||
|
||||
dHandle = self._project.newFile(docLabel, pHandle)
|
||||
hHandle[hLevel] = dHandle
|
||||
|
||||
newItem = self._project.tree[dHandle]
|
||||
if isinstance(newItem, NWItem):
|
||||
newItem.setStatus(self._srcItem.itemStatus)
|
||||
newItem.setImport(self._srcItem.itemImport)
|
||||
|
||||
outDoc = self._project.storage.getDocument(dHandle)
|
||||
status = outDoc.writeDocument("\n".join(docText))
|
||||
if not status:
|
||||
self._error = outDoc.getError()
|
||||
|
||||
yield status, dHandle, nHandle
|
||||
|
||||
hHandle[hLevel] = dHandle
|
||||
nHandle = dHandle
|
||||
pLevel = hLevel
|
||||
yield status
|
||||
|
||||
hHandle[hLevel] = dHandle
|
||||
pLevel = hLevel
|
||||
return
|
||||
|
||||
|
||||
@@ -269,36 +258,34 @@ class DocDuplicator:
|
||||
# Methods
|
||||
##
|
||||
|
||||
def duplicate(self, items: list[str]) -> Iterable[tuple[str, str | None]]:
|
||||
def duplicate(self, items: list[str]) -> list[str]:
|
||||
"""Run through a list of items, duplicate them, and copy the
|
||||
text content if they are documents.
|
||||
"""
|
||||
result = []
|
||||
after = True
|
||||
if items:
|
||||
nHandle = items[0]
|
||||
hMap: dict[str, str | None] = {t: None for t in items}
|
||||
for tHandle in items:
|
||||
newItem = self._project.tree.duplicate(tHandle)
|
||||
if newItem is None:
|
||||
return
|
||||
hMap[tHandle] = newItem.itemHandle
|
||||
if newItem.itemParent in hMap:
|
||||
newItem.setParent(hMap[newItem.itemParent])
|
||||
self._project.tree.updateItemData(newItem.itemHandle)
|
||||
if newItem.isFileType():
|
||||
newDoc = self._project.storage.getDocument(newItem.itemHandle)
|
||||
if newDoc.fileExists():
|
||||
return
|
||||
newDoc.writeDocument(self._project.storage.getDocumentText(tHandle))
|
||||
yield newItem.itemHandle, nHandle
|
||||
nHandle = None
|
||||
return
|
||||
if oldItem := self._project.tree[tHandle]:
|
||||
pHandle = hMap.get(oldItem.itemParent or "") or oldItem.itemParent
|
||||
if newItem := self._project.tree.duplicate(tHandle, pHandle, after):
|
||||
hMap[tHandle] = newItem.itemHandle
|
||||
if newItem.isFileType():
|
||||
self._project.copyFileContent(newItem.itemHandle, tHandle)
|
||||
newItem.notifyToRefresh()
|
||||
result.append(newItem.itemHandle)
|
||||
after = False
|
||||
else:
|
||||
break
|
||||
return result
|
||||
|
||||
|
||||
class DocSearch:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._regEx = QRegularExpression()
|
||||
self.setCaseSensitive(False)
|
||||
self._regEx = re.compile("")
|
||||
self._opts = re.UNICODE | re.IGNORECASE
|
||||
self._words = False
|
||||
self._escape = True
|
||||
return
|
||||
@@ -309,10 +296,9 @@ class DocSearch:
|
||||
|
||||
def setCaseSensitive(self, state: bool) -> None:
|
||||
"""Set the case sensitive search flag."""
|
||||
opts = QRegularExpression.PatternOption.UseUnicodePropertiesOption
|
||||
self._opts = re.UNICODE
|
||||
if not state:
|
||||
opts |= QRegularExpression.PatternOption.CaseInsensitiveOption
|
||||
self._regEx.setPatternOptions(opts)
|
||||
self._opts |= re.IGNORECASE
|
||||
return
|
||||
|
||||
def setWholeWords(self, state: bool) -> None:
|
||||
@@ -329,8 +315,8 @@ class DocSearch:
|
||||
self, project: NWProject, search: str
|
||||
) -> Iterable[tuple[NWItem, list[tuple[int, int, str]], bool]]:
|
||||
"""Iteratively search through documents in a project."""
|
||||
self._regEx.setPattern(self._buildPattern(search))
|
||||
logger.debug("Searching with pattern '%s'", self._regEx.pattern())
|
||||
self._regEx = re.compile(self._buildPattern(search), self._opts)
|
||||
logger.debug("Searching with pattern '%s'", self._regEx.pattern)
|
||||
storage = project.storage
|
||||
for item in project.tree:
|
||||
if item.isFileType():
|
||||
@@ -340,14 +326,12 @@ class DocSearch:
|
||||
|
||||
def searchText(self, text: str) -> tuple[list[tuple[int, int, str]], bool]:
|
||||
"""Search a piece of text for RegEx matches."""
|
||||
rxItt = self._regEx.globalMatch(text)
|
||||
count = 0
|
||||
capped = False
|
||||
results = []
|
||||
while rxItt.hasNext():
|
||||
rxMatch = rxItt.next()
|
||||
pos = rxMatch.capturedStart()
|
||||
num = rxMatch.capturedLength()
|
||||
for res in self._regEx.finditer(text):
|
||||
pos = res.start(0)
|
||||
num = len(res.group(0))
|
||||
lim = text[:pos].rfind("\n") + 1
|
||||
cut = text[lim:pos].rfind(" ") + lim + 1
|
||||
context = text[cut:cut+100].partition("\n")[0]
|
||||
@@ -366,7 +350,7 @@ class DocSearch:
|
||||
def _buildPattern(self, search: str) -> str:
|
||||
"""Build the search pattern string."""
|
||||
if self._escape:
|
||||
search = QRegularExpression.escape(search)
|
||||
search = re.escape(search)
|
||||
if self._words:
|
||||
search = f"(?:^|\\b){search}(?:$|\\b)"
|
||||
return search
|
||||
@@ -430,7 +414,6 @@ class ProjectBuilder:
|
||||
|
||||
lblNewProject = self.tr("New Project")
|
||||
lblTitlePage = self.tr("Title Page")
|
||||
lblByAuthors = self.tr("By")
|
||||
|
||||
# Settings
|
||||
project.data.setUuid(None)
|
||||
@@ -443,14 +426,29 @@ class ProjectBuilder:
|
||||
# Add Root Folders
|
||||
hNovelRoot = project.newRoot(nwItemClass.NOVEL)
|
||||
hTitlePage = project.newFile(lblTitlePage, hNovelRoot)
|
||||
novelTitle = project.data.name
|
||||
|
||||
titlePage = f"#! {novelTitle}\n\n"
|
||||
if project.data.author:
|
||||
titlePage += f">> {lblByAuthors} {project.data.author} <<\n\n"
|
||||
|
||||
# Generate Title Page
|
||||
aDoc = project.storage.getDocument(hTitlePage)
|
||||
aDoc.writeDocument(titlePage)
|
||||
aDoc.writeDocument((
|
||||
"{author}[br]\n"
|
||||
"{address} 1[br]\n"
|
||||
"{address} 2 <<\n"
|
||||
"\n"
|
||||
"[vspace:5]\n"
|
||||
"\n"
|
||||
"#! {title}\n"
|
||||
"\n"
|
||||
">> **{by} {author}** <<\n"
|
||||
"\n"
|
||||
">> {count}: [field:{field}] <<\n"
|
||||
).format(
|
||||
author=project.data.author or "None",
|
||||
address=self.tr("Address"),
|
||||
title=project.data.name or "None",
|
||||
by=self.tr("By"),
|
||||
count=self.tr("Word Count"),
|
||||
field=nwStats.WORDS_TEXT,
|
||||
))
|
||||
|
||||
# Create a project structure based on selected root folders
|
||||
# and a number of chapters and scenes selected in the
|
||||
@@ -511,7 +509,7 @@ class ProjectBuilder:
|
||||
|
||||
# Also add the archive and trash folders
|
||||
project.newRoot(nwItemClass.ARCHIVE)
|
||||
project.trashFolder()
|
||||
project.tree.trash # Triggers the creation of Trash
|
||||
|
||||
project.saveProject()
|
||||
project.closeProject()
|
||||
|
||||
@@ -6,7 +6,7 @@ File History:
|
||||
Created: 2022-12-01 [2.1b1] NWBuildDocument
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2022 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -35,13 +35,15 @@ from novelwriter.constants import nwLabels
|
||||
from novelwriter.core.buildsettings import BuildSettings
|
||||
from novelwriter.core.item import NWItem
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.core.tohtml import ToHtml
|
||||
from novelwriter.core.tokenizer import Tokenizer
|
||||
from novelwriter.core.tomarkdown import ToMarkdown
|
||||
from novelwriter.core.toodt import ToOdt
|
||||
from novelwriter.core.toqdoc import TextDocumentTheme, ToQTextDocument
|
||||
from novelwriter.enum import nwBuildFmt
|
||||
from novelwriter.error import formatException, logException
|
||||
from novelwriter.formats.todocx import ToDocX
|
||||
from novelwriter.formats.tohtml import ToHtml
|
||||
from novelwriter.formats.tokenizer import Tokenizer
|
||||
from novelwriter.formats.tomarkdown import ToMarkdown
|
||||
from novelwriter.formats.toodt import ToOdt
|
||||
from novelwriter.formats.toqdoc import ToQTextDocument
|
||||
from novelwriter.formats.toraw import ToRaw
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -85,20 +87,6 @@ class NWBuildDocument:
|
||||
"""
|
||||
return self._cache
|
||||
|
||||
##
|
||||
# Setters
|
||||
##
|
||||
|
||||
def setCountEnabled(self, state: bool) -> None:
|
||||
"""Turn on/off stats for builds."""
|
||||
self._count = state
|
||||
return
|
||||
|
||||
def setBuildOutline(self, state: bool) -> None:
|
||||
"""Turn on/off outline for builds."""
|
||||
self._outline = state
|
||||
return
|
||||
|
||||
##
|
||||
# Special Methods
|
||||
##
|
||||
@@ -125,182 +113,117 @@ class NWBuildDocument:
|
||||
self._queue.append(item.itemHandle)
|
||||
return
|
||||
|
||||
def iterBuildPreview(self, theme: TextDocumentTheme) -> Iterable[tuple[int, bool]]:
|
||||
def iterBuildPreview(self, newPage: bool) -> Iterable[tuple[int, bool]]:
|
||||
"""Build a preview QTextDocument."""
|
||||
makeObj = ToQTextDocument(self._project)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
|
||||
self._outline = True
|
||||
self._count = True
|
||||
|
||||
font = QFont()
|
||||
font.fromString(self._build.getStr("format.textFont"))
|
||||
|
||||
makeObj.initDocument(font, theme)
|
||||
for i, tHandle in enumerate(self._queue):
|
||||
self._error = None
|
||||
if filtered.get(tHandle, (False, 0))[0]:
|
||||
yield i, self._doBuild(makeObj, tHandle)
|
||||
else:
|
||||
yield i, False
|
||||
|
||||
makeObj.appendFootnotes()
|
||||
|
||||
self._error = None
|
||||
self._cache = makeObj
|
||||
|
||||
return
|
||||
|
||||
def iterBuild(self, path: Path, bFormat: nwBuildFmt) -> Iterable[tuple[int, bool]]:
|
||||
"""Wrapper for builders based on format."""
|
||||
if bFormat in (nwBuildFmt.ODT, nwBuildFmt.FODT):
|
||||
yield from self.iterBuildOpenDocument(path, bFormat == nwBuildFmt.FODT)
|
||||
elif bFormat in (nwBuildFmt.HTML, nwBuildFmt.J_HTML):
|
||||
yield from self.iterBuildHTML(path, asJson=bFormat == nwBuildFmt.J_HTML)
|
||||
elif bFormat in (nwBuildFmt.STD_MD, nwBuildFmt.EXT_MD):
|
||||
yield from self.iterBuildMarkdown(path, bFormat == nwBuildFmt.EXT_MD)
|
||||
elif bFormat in (nwBuildFmt.NWD, nwBuildFmt.J_NWD):
|
||||
yield from self.iterBuildNWD(path, asJson=bFormat == nwBuildFmt.J_NWD)
|
||||
return
|
||||
|
||||
def iterBuildOpenDocument(self, path: Path, isFlat: bool) -> Iterable[tuple[int, bool]]:
|
||||
"""Build an Open Document file."""
|
||||
makeObj = ToOdt(self._project, isFlat=isFlat)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
makeObj.initDocument()
|
||||
|
||||
for i, tHandle in enumerate(self._queue):
|
||||
self._error = None
|
||||
if filtered.get(tHandle, (False, 0))[0]:
|
||||
yield i, self._doBuild(makeObj, tHandle)
|
||||
else:
|
||||
yield i, False
|
||||
|
||||
makeObj.setShowNewPage(newPage)
|
||||
self._outline = True
|
||||
yield from self._iterBuild(makeObj, filtered)
|
||||
makeObj.closeDocument()
|
||||
self._error = None
|
||||
self._cache = makeObj
|
||||
return
|
||||
|
||||
def iterBuildDocument(self, path: Path, bFormat: nwBuildFmt) -> Iterable[tuple[int, bool]]:
|
||||
"""Wrapper for builders based on format."""
|
||||
self._error = None
|
||||
self._cache = None
|
||||
|
||||
if bFormat in (nwBuildFmt.J_HTML, nwBuildFmt.J_NWD):
|
||||
# Ensure that JSON output has the correct extension
|
||||
path = path.with_suffix(".json")
|
||||
|
||||
if bFormat in (nwBuildFmt.ODT, nwBuildFmt.FODT):
|
||||
makeObj = ToOdt(self._project, bFormat == nwBuildFmt.FODT)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
makeObj.initDocument()
|
||||
yield from self._iterBuild(makeObj, filtered)
|
||||
makeObj.closeDocument()
|
||||
|
||||
elif bFormat in (nwBuildFmt.HTML, nwBuildFmt.J_HTML):
|
||||
makeObj = ToHtml(self._project)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
makeObj.initDocument()
|
||||
yield from self._iterBuild(makeObj, filtered)
|
||||
makeObj.closeDocument()
|
||||
if not self._build.getBool("html.preserveTabs"):
|
||||
makeObj.replaceTabs()
|
||||
|
||||
elif bFormat in (nwBuildFmt.STD_MD, nwBuildFmt.EXT_MD):
|
||||
makeObj = ToMarkdown(self._project, bFormat == nwBuildFmt.EXT_MD)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
yield from self._iterBuild(makeObj, filtered)
|
||||
makeObj.closeDocument()
|
||||
if self._build.getBool("format.replaceTabs"):
|
||||
makeObj.replaceTabs(nSpaces=4, spaceChar=" ")
|
||||
|
||||
elif bFormat in (nwBuildFmt.NWD, nwBuildFmt.J_NWD):
|
||||
makeObj = ToRaw(self._project)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
yield from self._iterBuild(makeObj, filtered)
|
||||
makeObj.closeDocument()
|
||||
if self._build.getBool("format.replaceTabs"):
|
||||
makeObj.replaceTabs(nSpaces=4, spaceChar=" ")
|
||||
|
||||
elif bFormat == nwBuildFmt.DOCX:
|
||||
makeObj = ToDocX(self._project)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
makeObj.initDocument()
|
||||
yield from self._iterBuild(makeObj, filtered)
|
||||
makeObj.closeDocument()
|
||||
|
||||
elif bFormat == nwBuildFmt.PDF:
|
||||
makeObj = ToQTextDocument(self._project)
|
||||
makeObj.disableAnchors()
|
||||
filtered = self._setupBuild(makeObj)
|
||||
makeObj.initDocument(pdf=True)
|
||||
yield from self._iterBuild(makeObj, filtered)
|
||||
makeObj.closeDocument()
|
||||
|
||||
else:
|
||||
logger.error("Unsupported document format")
|
||||
return
|
||||
|
||||
self._error = None
|
||||
self._cache = makeObj
|
||||
|
||||
try:
|
||||
if isFlat:
|
||||
makeObj.saveFlatXML(path)
|
||||
else:
|
||||
makeObj.saveOpenDocText(path)
|
||||
makeObj.saveDocument(path)
|
||||
except Exception as exc:
|
||||
logException()
|
||||
self._error = formatException(exc)
|
||||
|
||||
return
|
||||
|
||||
def iterBuildHTML(self, path: Path | None, asJson: bool = False) -> Iterable[tuple[int, bool]]:
|
||||
"""Build an HTML file. If path is None, no file is saved. This
|
||||
is used for generating build previews.
|
||||
"""
|
||||
makeObj = ToHtml(self._project)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
|
||||
for i, tHandle in enumerate(self._queue):
|
||||
self._error = None
|
||||
if filtered.get(tHandle, (False, 0))[0]:
|
||||
yield i, self._doBuild(makeObj, tHandle)
|
||||
else:
|
||||
yield i, False
|
||||
|
||||
makeObj.appendFootnotes()
|
||||
|
||||
if not self._build.getBool("html.preserveTabs"):
|
||||
makeObj.replaceTabs()
|
||||
|
||||
self._error = None
|
||||
self._cache = makeObj
|
||||
|
||||
if isinstance(path, Path):
|
||||
try:
|
||||
if asJson:
|
||||
makeObj.saveHtmlJson(path)
|
||||
else:
|
||||
makeObj.saveHtml5(path)
|
||||
except Exception as exc:
|
||||
logException()
|
||||
self._error = formatException(exc)
|
||||
|
||||
return
|
||||
|
||||
def iterBuildMarkdown(self, path: Path, extendedMd: bool) -> Iterable[tuple[int, bool]]:
|
||||
"""Build a Markdown file."""
|
||||
makeObj = ToMarkdown(self._project)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
|
||||
makeObj.setExtendedMarkdown(extendedMd)
|
||||
if self._build.getBool("format.replaceTabs"):
|
||||
makeObj.replaceTabs(nSpaces=4, spaceChar=" ")
|
||||
|
||||
for i, tHandle in enumerate(self._queue):
|
||||
self._error = None
|
||||
if filtered.get(tHandle, (False, 0))[0]:
|
||||
yield i, self._doBuild(makeObj, tHandle)
|
||||
else:
|
||||
yield i, False
|
||||
|
||||
makeObj.appendFootnotes()
|
||||
|
||||
self._error = None
|
||||
self._cache = makeObj
|
||||
|
||||
try:
|
||||
makeObj.saveMarkdown(path)
|
||||
except Exception as exc:
|
||||
logException()
|
||||
self._error = formatException(exc)
|
||||
|
||||
return
|
||||
|
||||
def iterBuildNWD(self, path: Path | None, asJson: bool = False) -> Iterable[tuple[int, bool]]:
|
||||
"""Build a novelWriter Markdown file."""
|
||||
makeObj = ToMarkdown(self._project)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
|
||||
makeObj.setKeepMarkdown(True)
|
||||
|
||||
for i, tHandle in enumerate(self._queue):
|
||||
self._error = None
|
||||
if filtered.get(tHandle, (False, 0))[0]:
|
||||
yield i, self._doBuild(makeObj, tHandle, convert=False)
|
||||
else:
|
||||
yield i, False
|
||||
|
||||
if self._build.getBool("format.replaceTabs"):
|
||||
makeObj.replaceTabs(nSpaces=4, spaceChar=" ")
|
||||
|
||||
self._error = None
|
||||
self._cache = makeObj
|
||||
|
||||
if isinstance(path, Path):
|
||||
try:
|
||||
if asJson:
|
||||
makeObj.saveRawMarkdownJSON(path)
|
||||
else:
|
||||
makeObj.saveRawMarkdown(path)
|
||||
except Exception as exc:
|
||||
logException()
|
||||
self._error = formatException(exc)
|
||||
|
||||
return
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _iterBuild(self, makeObj: Tokenizer, filtered: dict) -> Iterable[tuple[int, bool]]:
|
||||
"""Iterate over buildable documents."""
|
||||
self._count = True
|
||||
for i, tHandle in enumerate(self._queue):
|
||||
self._error = None
|
||||
if filtered.get(tHandle, (False, 0))[0]:
|
||||
yield i, self._doBuild(makeObj, tHandle)
|
||||
else:
|
||||
yield i, False
|
||||
return
|
||||
|
||||
def _setupBuild(self, bldObj: Tokenizer) -> dict:
|
||||
"""Configure the build object."""
|
||||
# Get Settings
|
||||
textFont = QFont(CONFIG.textFont)
|
||||
textFont.fromString(self._build.getStr("format.textFont"))
|
||||
bldObj.setFont(textFont)
|
||||
|
||||
bldObj.setTitleFormat(
|
||||
self._build.getStr("headings.fmtTitle"),
|
||||
self._build.getBool("headings.hideTitle")
|
||||
bldObj.setTextFont(textFont)
|
||||
bldObj.setLanguage(self._project.data.language)
|
||||
|
||||
bldObj.setPartitionFormat(
|
||||
self._build.getStr("headings.fmtPart"),
|
||||
self._build.getBool("headings.hidePart")
|
||||
)
|
||||
bldObj.setChapterFormat(
|
||||
self._build.getStr("headings.fmtChapter"),
|
||||
@@ -326,6 +249,10 @@ class NWBuildDocument:
|
||||
self._build.getBool("headings.centerTitle"),
|
||||
self._build.getBool("headings.breakTitle")
|
||||
)
|
||||
bldObj.setPartitionStyle(
|
||||
self._build.getBool("headings.centerPart"),
|
||||
self._build.getBool("headings.breakPart")
|
||||
)
|
||||
bldObj.setChapterStyle(
|
||||
self._build.getBool("headings.centerChapter"),
|
||||
self._build.getBool("headings.breakChapter")
|
||||
@@ -338,12 +265,46 @@ class NWBuildDocument:
|
||||
bldObj.setJustify(self._build.getBool("format.justifyText"))
|
||||
bldObj.setLineHeight(self._build.getFloat("format.lineHeight"))
|
||||
bldObj.setKeepLineBreaks(self._build.getBool("format.keepBreaks"))
|
||||
bldObj.setDialogueHighlight(self._build.getBool("format.showDialogue"))
|
||||
bldObj.setDialogHighlight(self._build.getBool("format.showDialogue"))
|
||||
bldObj.setFirstLineIndent(
|
||||
self._build.getBool("format.firstLineIndent"),
|
||||
self._build.getFloat("format.firstIndentWidth"),
|
||||
self._build.getBool("format.indentFirstPar"),
|
||||
)
|
||||
bldObj.setHeadingStyles(
|
||||
self._build.getBool("doc.colorHeadings"),
|
||||
self._build.getBool("doc.scaleHeadings"),
|
||||
self._build.getBool("doc.boldHeadings"),
|
||||
)
|
||||
|
||||
bldObj.setTitleMargins(
|
||||
self._build.getFloat("format.titleMarginT"),
|
||||
self._build.getFloat("format.titleMarginB"),
|
||||
)
|
||||
bldObj.setHead1Margins(
|
||||
self._build.getFloat("format.h1MarginT"),
|
||||
self._build.getFloat("format.h1MarginB"),
|
||||
)
|
||||
bldObj.setHead2Margins(
|
||||
self._build.getFloat("format.h2MarginT"),
|
||||
self._build.getFloat("format.h2MarginB"),
|
||||
)
|
||||
bldObj.setHead3Margins(
|
||||
self._build.getFloat("format.h3MarginT"),
|
||||
self._build.getFloat("format.h3MarginB"),
|
||||
)
|
||||
bldObj.setHead4Margins(
|
||||
self._build.getFloat("format.h4MarginT"),
|
||||
self._build.getFloat("format.h4MarginB"),
|
||||
)
|
||||
bldObj.setTextMargins(
|
||||
self._build.getFloat("format.textMarginT"),
|
||||
self._build.getFloat("format.textMarginB"),
|
||||
)
|
||||
bldObj.setSeparatorMargins(
|
||||
self._build.getFloat("format.sepMarginT"),
|
||||
self._build.getFloat("format.sepMarginB"),
|
||||
)
|
||||
|
||||
bldObj.setBodyText(self._build.getBool("text.includeBodyText"))
|
||||
bldObj.setSynopsis(self._build.getBool("text.includeSynopsis"))
|
||||
@@ -355,13 +316,13 @@ class NWBuildDocument:
|
||||
bldObj.setStyles(self._build.getBool("html.addStyles"))
|
||||
bldObj.setReplaceUnicode(self._build.getBool("format.stripUnicode"))
|
||||
|
||||
if isinstance(bldObj, ToOdt):
|
||||
bldObj.setColourHeaders(self._build.getBool("odt.addColours"))
|
||||
bldObj.setLanguage(self._project.data.language)
|
||||
if isinstance(bldObj, (ToOdt, ToDocX)):
|
||||
bldObj.setHeaderFormat(
|
||||
self._build.getStr("odt.pageHeader"), self._build.getInt("odt.pageCountOffset")
|
||||
self._build.getStr("doc.pageHeader"),
|
||||
self._build.getInt("doc.pageCountOffset"),
|
||||
)
|
||||
|
||||
if isinstance(bldObj, (ToOdt, ToDocX, ToQTextDocument)):
|
||||
scale = nwLabels.UNIT_SCALE.get(self._build.getStr("format.pageUnit"), 1.0)
|
||||
pW, pH = nwLabels.PAPER_SIZE.get(self._build.getStr("format.pageSize"), (-1.0, -1.0))
|
||||
bldObj.setPageLayout(
|
||||
@@ -384,14 +345,17 @@ class NWBuildDocument:
|
||||
tItem = self._project.tree[tHandle]
|
||||
if isinstance(tItem, NWItem):
|
||||
try:
|
||||
if tItem.isRootType() and not tItem.isNovelLike():
|
||||
bldObj.addRootHeading(tHandle)
|
||||
if convert:
|
||||
bldObj.doConvert()
|
||||
if self._count:
|
||||
bldObj.countStats()
|
||||
if self._outline:
|
||||
bldObj.buildOutline()
|
||||
if tItem.isRootType():
|
||||
if tItem.isNovelLike():
|
||||
bldObj.setBreakNext()
|
||||
else:
|
||||
bldObj.addRootHeading(tHandle)
|
||||
if convert:
|
||||
bldObj.doConvert()
|
||||
if self._count:
|
||||
bldObj.countStats()
|
||||
if self._outline:
|
||||
bldObj.buildOutline()
|
||||
elif tItem.isFileType():
|
||||
bldObj.setText(tHandle)
|
||||
bldObj.doPreProcessing()
|
||||
@@ -402,8 +366,6 @@ class NWBuildDocument:
|
||||
bldObj.buildOutline()
|
||||
if convert:
|
||||
bldObj.doConvert()
|
||||
else:
|
||||
logger.info(f"Build: Skipping '{tHandle}'")
|
||||
|
||||
except Exception:
|
||||
self._error = f"Build: Failed to build '{tHandle}'"
|
||||
|
||||
@@ -6,7 +6,7 @@ File History:
|
||||
Created: 2018-09-29 [0.0.1]
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2018 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
|
||||
@@ -10,7 +10,7 @@ Created: 2022-05-29 [2.0rc1] TagsIndex
|
||||
Created: 2022-05-29 [2.0rc1] ItemIndex
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2019 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -40,7 +40,7 @@ from novelwriter import SHARED
|
||||
from novelwriter.common import (
|
||||
checkInt, isHandle, isItemClass, isListInstance, isTitleTag, jsonEncode
|
||||
)
|
||||
from novelwriter.constants import nwFiles, nwHeaders, nwKeyWords
|
||||
from novelwriter.constants import nwFiles, nwKeyWords, nwStyles
|
||||
from novelwriter.enum import nwComment, nwItemClass, nwItemLayout, nwItemType
|
||||
from novelwriter.error import logException
|
||||
from novelwriter.text.counting import standardCounter
|
||||
@@ -116,24 +116,24 @@ class NWIndex:
|
||||
# Public Methods
|
||||
##
|
||||
|
||||
def clearIndex(self) -> None:
|
||||
def clear(self) -> None:
|
||||
"""Clear the index dictionaries and time stamps."""
|
||||
self._tagsIndex.clear()
|
||||
self._itemIndex.clear()
|
||||
self._indexChange = 0.0
|
||||
self._rootChange = {}
|
||||
SHARED.indexSignalProxy({"event": "clearIndex"})
|
||||
SHARED.emitIndexCleared(self._project)
|
||||
return
|
||||
|
||||
def rebuildIndex(self) -> None:
|
||||
def rebuild(self) -> None:
|
||||
"""Rebuild the entire index from scratch."""
|
||||
self.clearIndex()
|
||||
self.clear()
|
||||
for nwItem in self._project.tree:
|
||||
if nwItem.isFileType():
|
||||
text = self._project.storage.getDocumentText(nwItem.itemHandle)
|
||||
self.scanText(nwItem.itemHandle, text, blockSignal=True)
|
||||
self._indexBroken = False
|
||||
SHARED.indexSignalProxy({"event": "buildIndex"})
|
||||
SHARED.emitIndexAvailable(self._project)
|
||||
return
|
||||
|
||||
def deleteHandle(self, tHandle: str) -> None:
|
||||
@@ -143,10 +143,7 @@ class NWIndex:
|
||||
for tTag in delTags:
|
||||
del self._tagsIndex[tTag]
|
||||
del self._itemIndex[tHandle]
|
||||
SHARED.indexSignalProxy({
|
||||
"event": "updateTags",
|
||||
"deleted": delTags,
|
||||
})
|
||||
SHARED.emitIndexChangedTags(self._project, [], delTags)
|
||||
return
|
||||
|
||||
def reIndexHandle(self, tHandle: str | None) -> None:
|
||||
@@ -212,7 +209,7 @@ class NWIndex:
|
||||
self.reIndexHandle(fHandle)
|
||||
|
||||
self._indexChange = time()
|
||||
SHARED.indexSignalProxy({"event": "buildIndex"})
|
||||
SHARED.emitIndexAvailable(self._project)
|
||||
|
||||
logger.debug("Index loaded in %.3f ms", (time() - tStart)*1000)
|
||||
|
||||
@@ -296,10 +293,7 @@ class NWIndex:
|
||||
self._indexChange = nowTime
|
||||
self._rootChange[tItem.itemRoot] = nowTime
|
||||
if not blockSignal:
|
||||
SHARED.indexSignalProxy({
|
||||
"event": "scanText",
|
||||
"handle": tHandle,
|
||||
})
|
||||
tItem.notifyToRefresh()
|
||||
|
||||
return True
|
||||
|
||||
@@ -370,11 +364,7 @@ class NWIndex:
|
||||
del self._tagsIndex[tTag]
|
||||
deleted.append(tTag)
|
||||
if updated or deleted:
|
||||
SHARED.indexSignalProxy({
|
||||
"event": "updateTags",
|
||||
"updated": updated,
|
||||
"deleted": deleted,
|
||||
})
|
||||
SHARED.emitIndexChangedTags(self._project, updated, deleted)
|
||||
|
||||
return
|
||||
|
||||
@@ -487,13 +477,14 @@ class NWIndex:
|
||||
if nBits == 0:
|
||||
return []
|
||||
|
||||
# Check that the key is valid
|
||||
isGood[0] = tBits[0] in nwKeyWords.VALID_KEYS
|
||||
# Check that the keyword is valid
|
||||
kBit = tBits[0]
|
||||
isGood[0] = kBit in nwKeyWords.VALID_KEYS
|
||||
if not isGood[0] or nBits == 1:
|
||||
return isGood
|
||||
|
||||
# For a tag, only the first value is accepted, the rest are ignored
|
||||
if tBits[0] == nwKeyWords.TAG_KEY and nBits > 1:
|
||||
if kBit == nwKeyWords.TAG_KEY and nBits > 1:
|
||||
check, _ = self.parseValue(tBits[1])
|
||||
if check in self._tagsIndex:
|
||||
isGood[1] = self._tagsIndex.tagHandle(check) == tHandle
|
||||
@@ -501,12 +492,16 @@ class NWIndex:
|
||||
isGood[1] = True
|
||||
return isGood
|
||||
|
||||
if kBit == nwKeyWords.MENTION_KEY and nBits > 1:
|
||||
isGood[1:nBits] = [aBit in self._tagsIndex for aBit in tBits[1:nBits]]
|
||||
return isGood
|
||||
|
||||
# If we're still here, we check that the references exist
|
||||
# Class references cannot have the | symbol in them
|
||||
refKey = nwKeyWords.KEY_CLASS[tBits[0]].name
|
||||
for n in range(1, nBits):
|
||||
if (aBit := tBits[n]) in self._tagsIndex:
|
||||
isGood[n] = self._tagsIndex.tagClass(aBit) == refKey and "|" not in aBit
|
||||
if rClass := nwKeyWords.KEY_CLASS.get(kBit):
|
||||
for n in range(1, nBits):
|
||||
if (aBit := tBits[n]) in self._tagsIndex:
|
||||
isGood[n] = self._tagsIndex.tagClass(aBit) == rClass.name and "|" not in aBit
|
||||
|
||||
return isGood
|
||||
|
||||
@@ -569,7 +564,7 @@ class NWIndex:
|
||||
for _, _, hItem in self._itemIndex.iterNovelStructure(
|
||||
rHandle=rootHandle, activeOnly=activeOnly
|
||||
):
|
||||
iLevel = nwHeaders.H_LEVEL.get(hItem.level, 0)
|
||||
iLevel = nwStyles.H_LEVEL.get(hItem.level, 0)
|
||||
hCount[iLevel] += 1
|
||||
return hCount
|
||||
|
||||
@@ -591,7 +586,7 @@ class NWIndex:
|
||||
rHandle=rHandle, activeOnly=activeOnly
|
||||
):
|
||||
tKey = f"{tHandle}:{sTitle}"
|
||||
iLevel = nwHeaders.H_LEVEL.get(hItem.level, 0)
|
||||
iLevel = nwStyles.H_LEVEL.get(hItem.level, 0)
|
||||
if iLevel > maxDepth:
|
||||
if pKey in tData:
|
||||
tData[pKey]["words"] += hItem.wordCount
|
||||
@@ -635,7 +630,7 @@ class NWIndex:
|
||||
"""Extract all references made in a file, and optionally title
|
||||
section.
|
||||
"""
|
||||
tRefs = {x: [] for x in nwKeyWords.KEY_CLASS}
|
||||
tRefs = {x: [] for x in nwKeyWords.VALID_KEYS}
|
||||
for rTitle, hItem in self._itemIndex.iterItemHeaders(tHandle):
|
||||
if sTitle is None or sTitle == rTitle:
|
||||
for aTag, refTypes in hItem.references.items():
|
||||
@@ -681,9 +676,10 @@ class NWIndex:
|
||||
"""Return all tags used by a specific document."""
|
||||
return self._itemIndex.allItemTags(tHandle) if tHandle else []
|
||||
|
||||
def getClassTags(self, itemClass: nwItemClass) -> list[str]:
|
||||
def getClassTags(self, itemClass: nwItemClass | None) -> list[str]:
|
||||
"""Return all tags based on itemClass."""
|
||||
return self._tagsIndex.filterTagNames(itemClass.name)
|
||||
name = None if itemClass is None else itemClass.name
|
||||
return self._tagsIndex.filterTagNames(name)
|
||||
|
||||
def getTagsData(
|
||||
self, activeOnly: bool = True
|
||||
@@ -780,11 +776,16 @@ class TagsIndex:
|
||||
"""Get the class of a given tag."""
|
||||
return self._tags.get(tagKey.lower(), {}).get("class", None)
|
||||
|
||||
def filterTagNames(self, className: str) -> list[str]:
|
||||
def filterTagNames(self, className: str | None) -> list[str]:
|
||||
"""Get a list of tag names for a given class."""
|
||||
return [
|
||||
x.get("name", "") for x in self._tags.values() if x.get("class", "") == className
|
||||
]
|
||||
if className is None:
|
||||
return [
|
||||
x.get("name", "") for x in self._tags.values()
|
||||
]
|
||||
else:
|
||||
return [
|
||||
x.get("name", "") for x in self._tags.values() if x.get("class", "") == className
|
||||
]
|
||||
|
||||
##
|
||||
# Pack/Unpack
|
||||
@@ -1251,7 +1252,7 @@ class IndexHeading:
|
||||
|
||||
def setLevel(self, level: str) -> None:
|
||||
"""Set the level of the heading if it's a valid value."""
|
||||
if level in nwHeaders.H_VALID:
|
||||
if level in nwStyles.H_VALID:
|
||||
self._level = level
|
||||
return
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ File History:
|
||||
Created: 2018-10-27 [0.0.1] NWItem
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2018 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -27,13 +27,14 @@ import logging
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from PyQt5.QtGui import QIcon
|
||||
from PyQt5.QtGui import QFont, QIcon
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.common import (
|
||||
checkInt, isHandle, isItemClass, isItemLayout, isItemType, simplified,
|
||||
yesNo
|
||||
)
|
||||
from novelwriter.constants import nwHeaders, nwLabels, trConst
|
||||
from novelwriter.constants import nwLabels, nwStyles, trConst
|
||||
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -256,6 +257,8 @@ class NWItem:
|
||||
self._paraCount = 0
|
||||
self._cursorPos = 0
|
||||
|
||||
self._initCount = self._wordCount
|
||||
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
@@ -281,6 +284,15 @@ class NWItem:
|
||||
cls._initCount = source._initCount
|
||||
return cls
|
||||
|
||||
##
|
||||
# Action Methods
|
||||
##
|
||||
|
||||
def notifyToRefresh(self) -> None:
|
||||
"""Notify GUI that item info needs to be refreshed."""
|
||||
self._project.tree.refreshItems([self._handle])
|
||||
return
|
||||
|
||||
##
|
||||
# Lookup Methods
|
||||
##
|
||||
@@ -309,6 +321,19 @@ class NWItem:
|
||||
|
||||
return trConst(nwLabels.ITEM_DESCRIPTION.get(descKey, ""))
|
||||
|
||||
def getMainIcon(self) -> QIcon:
|
||||
"""Get the main item icon."""
|
||||
return SHARED.theme.getItemIcon(self._type, self._class, self._layout, self._heading)
|
||||
|
||||
def getMainFont(self) -> QFont:
|
||||
"""Get the main item icon."""
|
||||
if CONFIG.emphLabels and self._layout == nwItemLayout.DOCUMENT:
|
||||
if self._heading == "H1":
|
||||
return SHARED.theme.guiFontBU
|
||||
elif self._heading == "H2":
|
||||
return SHARED.theme.guiFontB
|
||||
return SHARED.theme.guiFont
|
||||
|
||||
def getImportStatus(self) -> tuple[str, QIcon]:
|
||||
"""Return the relevant importance or status label and icon for
|
||||
the current item based on its class.
|
||||
@@ -319,6 +344,19 @@ class NWItem:
|
||||
entry = self._project.data.itemImport[self._import]
|
||||
return entry.name, entry.icon
|
||||
|
||||
def getActiveStatus(self) -> tuple[str, QIcon]:
|
||||
"""Return the relevant active status label and icon for
|
||||
the current item based on its type.
|
||||
"""
|
||||
if self.isFileType():
|
||||
key = "checked" if self._active else "unchecked"
|
||||
text = trConst(nwLabels.ACTIVE_NAME[key])
|
||||
icon = SHARED.theme.getIcon(key)
|
||||
else:
|
||||
text = ""
|
||||
icon = SHARED.theme.getIcon("noncheckable")
|
||||
return text, icon
|
||||
|
||||
##
|
||||
# Checker Methods
|
||||
##
|
||||
@@ -518,7 +556,7 @@ class NWItem:
|
||||
|
||||
def setMainHeading(self, value: str) -> None:
|
||||
"""Set the main heading level."""
|
||||
if value in nwHeaders.H_LEVEL:
|
||||
if value in nwStyles.H_LEVEL:
|
||||
self._heading = value
|
||||
return
|
||||
|
||||
@@ -553,8 +591,3 @@ class NWItem:
|
||||
else:
|
||||
self._cursorPos = 0
|
||||
return
|
||||
|
||||
def saveInitialCount(self) -> None:
|
||||
"""Save the initial word count."""
|
||||
self._initCount = self._wordCount
|
||||
return
|
||||
|
||||
518
novelwriter/core/itemmodel.py
Normal file
@@ -0,0 +1,518 @@
|
||||
"""
|
||||
novelWriter – Project Item Model
|
||||
================================
|
||||
|
||||
File History:
|
||||
Created: 2024-11-16 [2.6b2] ProjectNode
|
||||
Created: 2024-11-16 [2.6b2] ProjectModel
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright (C) 2024 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PyQt5.QtCore import QAbstractItemModel, QMimeData, QModelIndex, Qt
|
||||
from PyQt5.QtGui import QFont, QIcon
|
||||
|
||||
from novelwriter.common import decodeMimeHandles, encodeMimeHandles, minmax
|
||||
from novelwriter.constants import nwConst
|
||||
from novelwriter.core.item import NWItem
|
||||
from novelwriter.enum import nwItemClass
|
||||
from novelwriter.types import QtAlignRight
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from novelwriter.core.tree import NWTree
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INV_ROOT = "invisibleRoot"
|
||||
C_FACTOR = 0x0100
|
||||
|
||||
C_LABEL_TEXT = 0x0000 | Qt.ItemDataRole.DisplayRole
|
||||
C_LABEL_ICON = 0x0000 | Qt.ItemDataRole.DecorationRole
|
||||
C_LABEL_FONT = 0x0000 | Qt.ItemDataRole.FontRole
|
||||
C_COUNT_TEXT = 0x0100 | Qt.ItemDataRole.DisplayRole
|
||||
C_COUNT_ICON = 0x0100 | Qt.ItemDataRole.DecorationRole
|
||||
C_COUNT_ALIGN = 0x0100 | Qt.ItemDataRole.TextAlignmentRole
|
||||
C_ACTIVE_ICON = 0x0200 | Qt.ItemDataRole.DecorationRole
|
||||
C_ACTIVE_TIP = 0x0200 | Qt.ItemDataRole.ToolTipRole
|
||||
C_STATUS_ICON = 0x0300 | Qt.ItemDataRole.DecorationRole
|
||||
C_STATUS_TIP = 0x0300 | Qt.ItemDataRole.ToolTipRole
|
||||
|
||||
NODE_FLAGS = Qt.ItemFlag.ItemIsEnabled
|
||||
NODE_FLAGS |= Qt.ItemFlag.ItemIsSelectable
|
||||
NODE_FLAGS |= Qt.ItemFlag.ItemIsDropEnabled
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
# Requires Python 3.10
|
||||
T_NodeData = str | QIcon | QFont | Qt.AlignmentFlag | None
|
||||
|
||||
|
||||
class ProjectNode:
|
||||
"""Core: Project Model Node Class
|
||||
|
||||
The project tree structure is saved as nodes in a tree, starting
|
||||
from a root node. This class makes up these nodes.
|
||||
|
||||
Each node is a wrapper around an NWItem object. The NWItem is the
|
||||
object representing a single item in the project, and it only
|
||||
contains a reference to its parent as well as it top level root, but
|
||||
is itself not structured in a hierarchy in memory.
|
||||
|
||||
This class provides the necessary hierarchical structure, as well as
|
||||
the data entries needed for populating the GUI project tree. It also
|
||||
handles pushing and pulling information from its NWItem when
|
||||
necessary.
|
||||
|
||||
The data to be displayed could in principle be pulled from the
|
||||
NWItem whenever it is needed, but for performance reason it is
|
||||
cached, as the GUI will pull this information often.
|
||||
"""
|
||||
|
||||
C_NAME = 0
|
||||
C_COUNT = 1
|
||||
C_ACTIVE = 2
|
||||
C_STATUS = 3
|
||||
|
||||
__slots__ = ("_item", "_children", "_parent", "_row", "_cache", "_flags", "_count")
|
||||
|
||||
def __init__(self, item: NWItem) -> None:
|
||||
self._item = item
|
||||
self._children: list[ProjectNode] = []
|
||||
self._parent: ProjectNode | None = None
|
||||
self._row = 0
|
||||
self._cache: dict[int, T_NodeData] = {}
|
||||
self._flags = NODE_FLAGS
|
||||
self._count = 0
|
||||
self.refresh()
|
||||
self.updateCount()
|
||||
return
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<ProjectNode handle={self._item.itemHandle} "
|
||||
f"parent={self._parent.item.itemHandle if self._parent else None} "
|
||||
f"row={self._row} "
|
||||
f"children={len(self._children)}>"
|
||||
)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""A node should always evaluate to True."""
|
||||
return True
|
||||
|
||||
##
|
||||
# Properties
|
||||
##
|
||||
|
||||
@property
|
||||
def item(self) -> NWItem:
|
||||
"""The project item of the node."""
|
||||
return self._item
|
||||
|
||||
@property
|
||||
def children(self) -> list[ProjectNode]:
|
||||
"""All children of the node."""
|
||||
return self._children
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
"""The count of the node."""
|
||||
return self._count
|
||||
|
||||
##
|
||||
# Data Maintenance
|
||||
##
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Refresh data values."""
|
||||
# Label
|
||||
self._cache[C_LABEL_ICON] = self._item.getMainIcon()
|
||||
self._cache[C_LABEL_TEXT] = self._item.itemName
|
||||
self._cache[C_LABEL_FONT] = self._item.getMainFont()
|
||||
|
||||
# Count
|
||||
self._cache[C_COUNT_ALIGN] = QtAlignRight
|
||||
|
||||
# Active
|
||||
aText, aIcon = self._item.getActiveStatus()
|
||||
self._cache[C_ACTIVE_TIP] = aText
|
||||
self._cache[C_ACTIVE_ICON] = aIcon
|
||||
|
||||
# Status
|
||||
sText, sIcon = self._item.getImportStatus()
|
||||
self._cache[C_STATUS_TIP] = sText
|
||||
self._cache[C_STATUS_ICON] = sIcon
|
||||
|
||||
return
|
||||
|
||||
def updateCount(self, propagate: bool = True) -> None:
|
||||
"""Update counts, and propagate upwards in the tree."""
|
||||
self._count = self._item.wordCount + sum(c._count for c in self._children)
|
||||
self._cache[C_COUNT_TEXT] = f"{self._count:n}"
|
||||
if propagate and (parent := self._parent):
|
||||
parent.updateCount()
|
||||
return
|
||||
|
||||
##
|
||||
# Data Access
|
||||
##
|
||||
|
||||
def row(self) -> int:
|
||||
"""Return the node's row number."""
|
||||
return self._row
|
||||
|
||||
def childCount(self) -> int:
|
||||
"""Return the number of children of the node."""
|
||||
return len(self._children)
|
||||
|
||||
def data(self, column: int, role: Qt.ItemDataRole) -> T_NodeData:
|
||||
"""Return cached node data."""
|
||||
return self._cache.get(C_FACTOR*column | role)
|
||||
|
||||
def flags(self) -> Qt.ItemFlag:
|
||||
"""Return cached node flags."""
|
||||
return self._flags
|
||||
|
||||
def parent(self) -> ProjectNode | None:
|
||||
"""Return the parent of the node."""
|
||||
return self._parent
|
||||
|
||||
def child(self, row: int) -> ProjectNode | None:
|
||||
"""Return a child ofg the node."""
|
||||
if 0 <= row < len(self._children):
|
||||
return self._children[row]
|
||||
return None
|
||||
|
||||
def allChildren(self) -> list[ProjectNode]:
|
||||
"""Return a recursive list of all children."""
|
||||
nodes: list[ProjectNode] = []
|
||||
self._recursiveAppendChildren(nodes)
|
||||
return nodes
|
||||
|
||||
##
|
||||
# Data Edit
|
||||
##
|
||||
|
||||
def addChild(self, child: ProjectNode, pos: int = -1) -> None:
|
||||
"""Add a child item to this item."""
|
||||
child._parent = self
|
||||
self._updateRelationships(child)
|
||||
if 0 <= pos < len(self._children):
|
||||
self._children.insert(pos, child)
|
||||
else:
|
||||
child._row = len(self._children)
|
||||
self._children.append(child)
|
||||
self._refreshChildrenPos()
|
||||
return
|
||||
|
||||
def takeChild(self, pos: int) -> ProjectNode | None:
|
||||
"""Remove a child item and return it."""
|
||||
if 0 <= pos < len(self._children):
|
||||
node = self._children.pop(pos)
|
||||
self._refreshChildrenPos()
|
||||
self.updateCount()
|
||||
return node
|
||||
return None
|
||||
|
||||
def moveChild(self, source: int, target: int) -> None:
|
||||
"""Move a child internally."""
|
||||
count = len(self._children)
|
||||
if (source != target) and (0 <= source < count) and (0 <= target <= count):
|
||||
node = self._children.pop(source)
|
||||
self._children.insert(target, node)
|
||||
self._refreshChildrenPos()
|
||||
return
|
||||
|
||||
def setExpanded(self, state: bool) -> None:
|
||||
"""Set the node's expanded state."""
|
||||
if state and self._children:
|
||||
self._item.setExpanded(True)
|
||||
else:
|
||||
self._item.setExpanded(False)
|
||||
return
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _recursiveAppendChildren(self, children: list[ProjectNode]) -> None:
|
||||
"""Recursively add all nodes to a list."""
|
||||
for node in self._children:
|
||||
children.append(node)
|
||||
node._recursiveAppendChildren(children)
|
||||
return
|
||||
|
||||
def _refreshChildrenPos(self) -> None:
|
||||
"""Update the row value on all children."""
|
||||
for n, child in enumerate(self._children):
|
||||
child._row = n
|
||||
child.item.setOrder(n)
|
||||
return
|
||||
|
||||
def _updateRelationships(self, child: ProjectNode) -> None:
|
||||
"""Update a child item's relationships."""
|
||||
if self._parent:
|
||||
child.item.setParent(self._item.itemHandle)
|
||||
child.item.setRoot(self._item.itemRoot)
|
||||
child.item.setClassDefaults(self._item.itemClass)
|
||||
child._flags = NODE_FLAGS | Qt.ItemFlag.ItemIsDragEnabled
|
||||
else:
|
||||
child.item.setParent(None)
|
||||
child.item.setRoot(child.item.itemHandle)
|
||||
child.item.setClassDefaults(child.item.itemClass)
|
||||
return
|
||||
|
||||
|
||||
class ProjectModel(QAbstractItemModel):
|
||||
"""Core: Project Model Class
|
||||
|
||||
This class provides the interface for the tree widget used on the
|
||||
GUI. It implements the QModelIndex based interface required, adds
|
||||
support for drag and drop, and a few other novelWriter-specific
|
||||
methods needed primarily by the project tree GUI component.
|
||||
"""
|
||||
|
||||
__slots__ = ("_tree", "_root")
|
||||
|
||||
def __init__(self, tree: NWTree) -> None:
|
||||
super().__init__()
|
||||
self._tree = tree
|
||||
self._root = ProjectNode(NWItem(tree._project, INV_ROOT))
|
||||
self._root.item.setName("Invisible Root")
|
||||
logger.debug("Ready: ProjectModel")
|
||||
return
|
||||
|
||||
def __del__(self) -> None: # pragma: no cover
|
||||
logger.debug("Delete: ProjectModel")
|
||||
return
|
||||
|
||||
##
|
||||
# Properties
|
||||
##
|
||||
|
||||
@property
|
||||
def root(self) -> ProjectNode:
|
||||
"""Return the model root item."""
|
||||
return self._root
|
||||
|
||||
##
|
||||
# Model Interface
|
||||
##
|
||||
|
||||
def rowCount(self, index: QModelIndex) -> int:
|
||||
"""Return the number of rows for an entry."""
|
||||
if index.isValid():
|
||||
return index.internalPointer().childCount()
|
||||
return self._root.childCount()
|
||||
|
||||
def columnCount(self, index: QModelIndex) -> int:
|
||||
"""Return the number of columns for an entry."""
|
||||
return 4
|
||||
|
||||
def parent(self, index: QModelIndex) -> QModelIndex:
|
||||
"""Get the parent model index of another index."""
|
||||
if index.isValid() and (parent := index.internalPointer().parent()):
|
||||
return self.createIndex(parent.row(), 0, parent)
|
||||
return QModelIndex()
|
||||
|
||||
def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex:
|
||||
"""get the index of a child item of a parent."""
|
||||
if self.hasIndex(row, column, parent):
|
||||
node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
|
||||
if child := node.child(row):
|
||||
return self.createIndex(row, column, child)
|
||||
return QModelIndex()
|
||||
|
||||
def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> T_NodeData:
|
||||
"""Return display data for a project node."""
|
||||
if index.isValid():
|
||||
return index.internalPointer().data(index.column(), role)
|
||||
return None
|
||||
|
||||
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
|
||||
"""Return flags for a project node."""
|
||||
if index.isValid():
|
||||
return index.internalPointer().flags()
|
||||
return Qt.ItemFlag.NoItemFlags
|
||||
|
||||
##
|
||||
# Drag and Drop
|
||||
##
|
||||
|
||||
def supportedDropActions(self) -> Qt.DropAction:
|
||||
"""Return supported drop actions"""
|
||||
return Qt.DropAction.MoveAction
|
||||
|
||||
def mimeTypes(self) -> list[str]:
|
||||
"""Return the supported mime types of the model."""
|
||||
return [nwConst.MIME_HANDLE]
|
||||
|
||||
def mimeData(self, indices: list[QModelIndex]) -> QMimeData:
|
||||
"""Encode mime data about a selection."""
|
||||
handles = [
|
||||
i.internalPointer().item.itemHandle
|
||||
for i in indices if i.isValid() and i.column() == 0
|
||||
]
|
||||
mime = QMimeData()
|
||||
encodeMimeHandles(mime, handles)
|
||||
return mime
|
||||
|
||||
def canDropMimeData(
|
||||
self, data: QMimeData, action: Qt.DropAction,
|
||||
row: int, column: int, parent: QModelIndex
|
||||
) -> bool:
|
||||
"""Check if mime data can be dropped on the current location."""
|
||||
return data.hasFormat(nwConst.MIME_HANDLE) and action == Qt.DropAction.MoveAction
|
||||
|
||||
def dropMimeData(
|
||||
self, data: QMimeData, action: Qt.DropAction,
|
||||
row: int, column: int, parent: QModelIndex
|
||||
) -> bool:
|
||||
"""Process mime data drop."""
|
||||
if self.canDropMimeData(data, action, row, column, parent):
|
||||
items = []
|
||||
for handle in decodeMimeHandles(data):
|
||||
if (index := self.indexFromHandle(handle)).isValid():
|
||||
items.append(index)
|
||||
self.multiMove(items, parent, row)
|
||||
return True
|
||||
return False
|
||||
|
||||
##
|
||||
# Data Access
|
||||
##
|
||||
|
||||
def row(self, index: QModelIndex) -> int:
|
||||
"""Return the row number of the index."""
|
||||
if index.isValid():
|
||||
return index.internalPointer().row()
|
||||
return -1
|
||||
|
||||
def node(self, index: QModelIndex) -> ProjectNode | None:
|
||||
"""Return the node for a given model index."""
|
||||
if index.isValid():
|
||||
return index.internalPointer()
|
||||
return None
|
||||
|
||||
def nodes(self, indices: list[QModelIndex]) -> list[ProjectNode]:
|
||||
"""Return the nodes for a list of model indices."""
|
||||
return [i.internalPointer() for i in indices if i.isValid() and i.column() == 0]
|
||||
|
||||
def indexFromHandle(self, handle: str | None) -> QModelIndex:
|
||||
"""Get the index representing a node in the model."""
|
||||
if handle and (node := self._tree.nodes.get(handle)):
|
||||
return self.createIndex(node.row(), 0, node)
|
||||
return QModelIndex()
|
||||
|
||||
def indexFromNode(self, node: ProjectNode, column: int = 0) -> QModelIndex:
|
||||
"""Get the index representing a node in the model."""
|
||||
return self.createIndex(node.row(), column, node)
|
||||
|
||||
##
|
||||
# Model Edit
|
||||
##
|
||||
|
||||
def insertChild(self, child: ProjectNode, parent: QModelIndex, pos: int) -> None:
|
||||
"""Insert a node into the model at a given position."""
|
||||
node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
|
||||
count = node.childCount()
|
||||
row = minmax(pos, 0, count) if pos >= 0 else count
|
||||
self.beginInsertRows(parent, row, row)
|
||||
node.addChild(child, row)
|
||||
self.endInsertRows()
|
||||
return
|
||||
|
||||
def removeChild(self, parent: QModelIndex, pos: int) -> ProjectNode | None:
|
||||
"""Remove a node from the model and return it."""
|
||||
node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
|
||||
if 0 <= pos < node.childCount():
|
||||
self.beginRemoveRows(parent, pos, pos)
|
||||
child = node.takeChild(pos)
|
||||
self.endRemoveRows()
|
||||
return child
|
||||
return None
|
||||
|
||||
def internalMove(self, index: QModelIndex, step: int) -> None:
|
||||
"""Move an item internally among its siblings."""
|
||||
if index.isValid():
|
||||
node: ProjectNode = index.internalPointer()
|
||||
if parent := node.parent():
|
||||
pos = index.row()
|
||||
new = minmax(pos + step, 0, parent.childCount() - 1)
|
||||
if new != pos:
|
||||
end = new if new < pos else new + 1
|
||||
self.beginMoveRows(index.parent(), pos, pos, index.parent(), end)
|
||||
parent.moveChild(pos, new)
|
||||
self.endMoveRows()
|
||||
return
|
||||
|
||||
def multiMove(self, indices: list[QModelIndex], target: QModelIndex, pos: int = -1) -> None:
|
||||
"""Move multiple items to a new location."""
|
||||
if target.isValid():
|
||||
# This is a two pass process. First we only select unique
|
||||
# non-root items for move, then we do a second pass and only
|
||||
# move those items that don't have a parent also scheduled
|
||||
# for moving or have already been moved. Child items are
|
||||
# moved with the parent.
|
||||
pruned = []
|
||||
handles = set()
|
||||
for index in indices:
|
||||
if index.isValid():
|
||||
node: ProjectNode = index.internalPointer()
|
||||
handle = node.item.itemHandle
|
||||
if node.item.isRootType() is False and handle not in handles:
|
||||
pruned.append(node)
|
||||
handles.add(handle)
|
||||
for node in (reversed(pruned) if pos >= 0 else pruned):
|
||||
if node.item.itemParent not in handles:
|
||||
index = self.indexFromNode(node)
|
||||
if temp := self.removeChild(index.parent(), index.row()):
|
||||
self.insertChild(temp, target, pos)
|
||||
for child in reversed(node.allChildren()):
|
||||
node._updateRelationships(child)
|
||||
child.item.notifyToRefresh()
|
||||
node.item.notifyToRefresh()
|
||||
return
|
||||
|
||||
##
|
||||
# Other Methods
|
||||
##
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the project model."""
|
||||
self._root._children.clear()
|
||||
return
|
||||
|
||||
def allExpanded(self) -> list[QModelIndex]:
|
||||
"""Return a list of all expanded items."""
|
||||
expanded = []
|
||||
for node in self._root.allChildren():
|
||||
if node._item.isExpanded:
|
||||
expanded.append(self.createIndex(node.row(), 0, node))
|
||||
return expanded
|
||||
|
||||
def trashSelection(self, indices: list[QModelIndex]) -> bool:
|
||||
"""Check if a selection of indices are all in trash or not."""
|
||||
for index in indices:
|
||||
if index.isValid():
|
||||
node: ProjectNode = index.internalPointer()
|
||||
if node.item.itemClass != nwItemClass.TRASH:
|
||||
return False
|
||||
return True
|
||||
@@ -7,7 +7,7 @@ Created: 2019-10-21 [0.3.1] OptionState
|
||||
Rewritten: 2020-02-19 [0.4.5] OptionState
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2019 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -60,7 +60,7 @@ VALID_MAP: dict[str, set[str]] = {
|
||||
},
|
||||
"GuiManuscript": {
|
||||
"winWidth", "winHeight", "optsWidth", "viewWidth", "listHeight",
|
||||
"detailsHeight", "detailsWidth", "detailsExpanded",
|
||||
"detailsHeight", "detailsWidth", "detailsExpanded", "showNewPage",
|
||||
},
|
||||
"GuiManuscriptBuild": {
|
||||
"winWidth", "winHeight", "fmtWidth", "sumWidth",
|
||||
@@ -73,6 +73,9 @@ VALID_MAP: dict[str, set[str]] = {
|
||||
"widthCol3", "widthCol4", "wordsPerPage", "countFrom", "clearDouble",
|
||||
"novelRoot",
|
||||
},
|
||||
"GuiOutlineDetails": {
|
||||
"detailsWidth", "tagsWidth",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ File History:
|
||||
Created: 2018-09-29 [0.0.1] NWProject
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2018 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -26,7 +26,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from collections.abc import Iterable
|
||||
from enum import Enum
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
@@ -52,7 +51,8 @@ from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
|
||||
from novelwriter.error import logException
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from novelwriter.core.item import NWItem
|
||||
# Requires Python 3.10
|
||||
from novelwriter.core.status import T_StatusKind, T_UpdateEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,6 +67,11 @@ class NWProjectState(Enum):
|
||||
|
||||
class NWProject:
|
||||
|
||||
__slots__ = (
|
||||
"_options", "_storage", "_data", "_tree", "_index", "_session",
|
||||
"_langData", "_changed", "_valid", "_state", "tr",
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
||||
# Core Elements
|
||||
@@ -94,6 +99,12 @@ class NWProject:
|
||||
logger.debug("Delete: NWProject")
|
||||
return
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the project."""
|
||||
self._tree.clear()
|
||||
self._index.clear()
|
||||
return
|
||||
|
||||
##
|
||||
# Properties
|
||||
##
|
||||
@@ -150,24 +161,46 @@ class NWProject:
|
||||
"""Return total edit time, including the current session."""
|
||||
return self._data.editTime + round(time() - self._session.start)
|
||||
|
||||
@property
|
||||
def currentTotalCount(self) -> int:
|
||||
"""Return the current total word count from the tree."""
|
||||
return self._tree.model.root.count
|
||||
|
||||
##
|
||||
# Item Methods
|
||||
##
|
||||
|
||||
def newRoot(self, itemClass: nwItemClass, label: str | None = None) -> str:
|
||||
def newRoot(self, itemClass: nwItemClass, pos: int = -1) -> str:
|
||||
"""Add a new root folder to the project. If label is not set,
|
||||
use the class label.
|
||||
"""
|
||||
label = label or trConst(nwLabels.CLASS_NAME[itemClass])
|
||||
return self._tree.create(label, None, nwItemType.ROOT, itemClass)
|
||||
label = trConst(nwLabels.CLASS_NAME[itemClass])
|
||||
return self._tree.create(label, None, nwItemType.ROOT, itemClass=itemClass, pos=pos)
|
||||
|
||||
def newFolder(self, label: str, parent: str) -> str | None:
|
||||
def newFolder(self, label: str, parent: str, pos: int = -1) -> str | None:
|
||||
"""Add a new folder with a given label and parent item."""
|
||||
return self._tree.create(label, parent, nwItemType.FOLDER)
|
||||
return self._tree.create(label, parent, nwItemType.FOLDER, pos=pos)
|
||||
|
||||
def newFile(self, label: str, parent: str) -> str | None:
|
||||
def newFile(self, label: str, parent: str, pos: int = -1) -> str | None:
|
||||
"""Add a new file with a given label and parent item."""
|
||||
return self._tree.create(label, parent, nwItemType.FILE)
|
||||
return self._tree.create(label, parent, nwItemType.FILE, pos=pos)
|
||||
|
||||
def removeItem(self, tHandle: str) -> bool:
|
||||
"""Remove an item from the project. This will delete both the
|
||||
project entry and a document file if it exists.
|
||||
"""
|
||||
if self._tree.checkType(tHandle, nwItemType.FILE):
|
||||
SHARED.closeDocument(tHandle)
|
||||
doc = self._storage.getDocument(tHandle)
|
||||
if not doc.deleteDocument():
|
||||
SHARED.error(
|
||||
self.tr("Could not delete document file."),
|
||||
info=doc.getError()
|
||||
)
|
||||
return False
|
||||
self._index.deleteHandle(tHandle)
|
||||
self._tree.remove(tHandle)
|
||||
return True
|
||||
|
||||
def writeNewFile(self, tHandle: str, hLevel: int, isDocument: bool, text: str = "") -> bool:
|
||||
"""Write content to a new document after it is created. This
|
||||
@@ -209,35 +242,21 @@ class NWProject:
|
||||
text = self._storage.getDocumentText(sHandle)
|
||||
self._storage.getDocument(tHandle).writeDocument(text)
|
||||
sItem.setLayout(tItem.itemLayout)
|
||||
self._index.scanText(tHandle, text)
|
||||
self._index.reIndexHandle(tHandle)
|
||||
|
||||
return True
|
||||
|
||||
def removeItem(self, tHandle: str) -> bool:
|
||||
"""Remove an item from the project. This will delete both the
|
||||
project entry and a document file if it exists.
|
||||
def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
|
||||
"""Create a new note. This function is used by the document
|
||||
editor to create note files for unknown tags.
|
||||
"""
|
||||
if self._tree.checkType(tHandle, nwItemType.FILE):
|
||||
delDoc = self._storage.getDocument(tHandle)
|
||||
if not delDoc.deleteDocument():
|
||||
SHARED.error(
|
||||
self.tr("Could not delete document file."),
|
||||
info=delDoc.getError()
|
||||
)
|
||||
return False
|
||||
|
||||
self._index.deleteHandle(tHandle)
|
||||
del self._tree[tHandle]
|
||||
|
||||
return True
|
||||
|
||||
def trashFolder(self) -> str:
|
||||
"""Add the special trash root folder to the project."""
|
||||
trashHandle = self._tree.trashRoot
|
||||
if trashHandle is None:
|
||||
label = trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH])
|
||||
return self._tree.create(label, None, nwItemType.ROOT, nwItemClass.TRASH)
|
||||
return trashHandle
|
||||
if itemClass != nwItemClass.NO_CLASS:
|
||||
if not (rHandle := self._tree.findRoot(itemClass)):
|
||||
rHandle = self.newRoot(itemClass)
|
||||
if rHandle and (tHandle := SHARED.project.newFile(tag.title(), rHandle)):
|
||||
self.writeNewFile(tHandle, 1, False, f"@tag: {tag}\n\n")
|
||||
self._tree.refreshItems([tHandle])
|
||||
return
|
||||
|
||||
##
|
||||
# Project Methods
|
||||
@@ -349,13 +368,14 @@ class NWProject:
|
||||
self._index.loadIndex()
|
||||
if xmlReader.state == XMLReadState.WAS_LEGACY:
|
||||
# Often, the index needs to be rebuilt when updating format
|
||||
self._index.rebuildIndex()
|
||||
self._index.rebuild()
|
||||
|
||||
self.updateWordCounts()
|
||||
self._session.startSession()
|
||||
self.setProjectChanged(False)
|
||||
self._valid = True
|
||||
self._state = NWProjectState.READY
|
||||
self._storage.lockSession() # Lock only after a successful open. See issue #1977.
|
||||
|
||||
SHARED.newStatusMessage(self.tr("Opened Project: {0}").format(self._data.name))
|
||||
|
||||
@@ -413,12 +433,11 @@ class NWProject:
|
||||
def closeProject(self, idleTime: float = 0.0) -> None:
|
||||
"""Close the project."""
|
||||
logger.info("Closing project")
|
||||
self._index.clearIndex() # Triggers clear signal, see #1718
|
||||
self._index.clear() # Triggers clear signal, see #1718
|
||||
self._options.saveSettings()
|
||||
self._tree.writeToCFile()
|
||||
self._session.appendSession(idleTime)
|
||||
self._storage.closeSession()
|
||||
self._lockedBy = None
|
||||
return
|
||||
|
||||
def backupProject(self, doNotify: bool) -> bool:
|
||||
@@ -488,17 +507,6 @@ class NWProject:
|
||||
self.setProjectChanged(True)
|
||||
return
|
||||
|
||||
def setTreeOrder(self, order: list[str]) -> None:
|
||||
"""A list representing the linear/flattened order of project
|
||||
items in the GUI project tree. The user can rearrange the order
|
||||
by drag-and-drop. Forwarded to the NWTree class.
|
||||
"""
|
||||
if len(self._tree) != len(order):
|
||||
logger.warning("Sizes of new and old tree order do not match")
|
||||
self._tree.setOrder(order)
|
||||
self.setProjectChanged(True)
|
||||
return
|
||||
|
||||
def setProjectChanged(self, status: bool) -> bool:
|
||||
"""Toggle the project changed flag, and propagate the
|
||||
information to the GUI statusbar.
|
||||
@@ -512,47 +520,6 @@ class NWProject:
|
||||
# Class Methods
|
||||
##
|
||||
|
||||
def iterProjectItems(self) -> Iterable[NWItem]:
|
||||
"""This function ensures that the item tree loaded is sent to
|
||||
the GUI tree view in such a way that the tree can be built. That
|
||||
is, the parent item must be sent before its child. In principle,
|
||||
a proper XML file will already ensure that, but in the event the
|
||||
order has been altered, or a file is orphaned, this function is
|
||||
capable of handling it.
|
||||
"""
|
||||
sentItems = set()
|
||||
iterItems = self._tree.handles()
|
||||
n = 0
|
||||
nMax = min(len(iterItems), 10000)
|
||||
while n < nMax:
|
||||
tHandle = iterItems[n]
|
||||
tItem = self._tree[tHandle]
|
||||
n += 1
|
||||
if tItem is None:
|
||||
# Technically a bug
|
||||
continue
|
||||
elif tItem.itemParent is None:
|
||||
# Item is a root, or already been identified as orphaned
|
||||
sentItems.add(tHandle)
|
||||
yield tItem
|
||||
elif tItem.itemParent in sentItems:
|
||||
# Item's parent has been sent, so all is fine
|
||||
sentItems.add(tHandle)
|
||||
yield tItem
|
||||
elif tItem.itemParent in iterItems:
|
||||
# Item's parent exists, but hasn't been sent yet, so add
|
||||
# it again to the end, but make sure this doesn't get
|
||||
# out hand, so we cap at 10000 items
|
||||
logger.warning("Item '%s' found before its parent", tHandle)
|
||||
iterItems.append(tHandle)
|
||||
nMax = min(len(iterItems), 10000)
|
||||
else:
|
||||
# Item is orphaned
|
||||
logger.error("Item '%s' has no parent in current tree", tHandle)
|
||||
tItem.setParent(None)
|
||||
yield tItem
|
||||
return
|
||||
|
||||
def updateWordCounts(self) -> None:
|
||||
"""Update the total word count values."""
|
||||
novel, notes = self._tree.sumWords()
|
||||
@@ -573,6 +540,18 @@ class NWProject:
|
||||
self._data.itemImport.increment(nwItem.itemImport)
|
||||
return
|
||||
|
||||
def updateStatus(self, kind: T_StatusKind, update: T_UpdateEntry) -> None:
|
||||
"""Update status or import entries."""
|
||||
if kind == "s":
|
||||
self._data.itemStatus.update(update)
|
||||
SHARED.emitStatusLabelsChanged(self, kind)
|
||||
self._tree.refreshAllItems()
|
||||
elif kind == "i":
|
||||
self._data.itemImport.update(update)
|
||||
SHARED.emitStatusLabelsChanged(self, kind)
|
||||
self._tree.refreshAllItems()
|
||||
return
|
||||
|
||||
def localLookup(self, word: str | int) -> str:
|
||||
"""Look up a word or number in the translation map for the
|
||||
project and return it. The variable is cast to a string before
|
||||
|
||||
@@ -6,7 +6,7 @@ File History:
|
||||
Created: 2022-10-30 [2.0rc2] NWProjectData
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2022 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -29,7 +29,8 @@ import uuid
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from novelwriter.common import (
|
||||
checkBool, checkInt, checkStringNone, checkUuid, isHandle, simplified
|
||||
checkBool, checkInt, checkStringNone, checkUuid, isHandle,
|
||||
makeFileNameSafe, simplified
|
||||
)
|
||||
from novelwriter.core.status import NWStatus
|
||||
|
||||
@@ -101,6 +102,11 @@ class NWProjectData:
|
||||
"""Return the project name."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def fileSafeName(self) -> str:
|
||||
"""Return the project name in a file name safe format."""
|
||||
return makeFileNameSafe(self._name)
|
||||
|
||||
@property
|
||||
def author(self) -> str:
|
||||
"""Return the project author."""
|
||||
|
||||
@@ -8,7 +8,7 @@ Created: 2022-09-28 [2.0rc2] ProjectXMLReader
|
||||
Created: 2022-10-31 [2.0rc2] ProjectXMLWriter
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2022 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
|
||||
@@ -6,7 +6,7 @@ File History:
|
||||
Created: 2023-06-11 [2.1b1] NWSessionLog
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2023 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
|
||||
@@ -7,7 +7,7 @@ Created: 2019-06-11 [0.1.5] NWSpellEnchant
|
||||
Created: 2023-06-13 [2.1b1] UserDictionary
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2019 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -125,21 +125,16 @@ class NWSpellEnchant:
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def addWord(self, word: str) -> bool:
|
||||
def addWord(self, word: str, save: bool = True) -> None:
|
||||
"""Add a word to the project dictionary."""
|
||||
word = word.strip()
|
||||
if not word:
|
||||
return False
|
||||
try:
|
||||
self._enchant.add_to_session(word)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
added = self._userDict.add(word)
|
||||
if added:
|
||||
self._userDict.save()
|
||||
|
||||
return added
|
||||
if word := word.strip():
|
||||
try:
|
||||
self._enchant.add_to_session(word)
|
||||
except Exception:
|
||||
return
|
||||
if save and self._userDict.add(word):
|
||||
self._userDict.save()
|
||||
return
|
||||
|
||||
def listDictionaries(self) -> list[tuple[str, str]]:
|
||||
"""List available dictionaries."""
|
||||
|
||||
@@ -7,7 +7,7 @@ Created: 2019-05-19 [0.1.3] NWStatus
|
||||
Rewritten: 2022-04-05 [2.0b1] NWStatus
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2019 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -37,7 +37,7 @@ from PyQt5.QtGui import QColor, QIcon, QPainter, QPainterPath, QPixmap, QPolygon
|
||||
from novelwriter import SHARED
|
||||
from novelwriter.common import simplified
|
||||
from novelwriter.enum import nwStatusShape
|
||||
from novelwriter.types import QtPaintAnitAlias, QtTransparent
|
||||
from novelwriter.types import QtPaintAntiAlias, QtTransparent
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from typing import TypeGuard # Requires Python 3.10
|
||||
@@ -65,6 +65,11 @@ class StatusEntry:
|
||||
|
||||
NO_ENTRY = StatusEntry("", QColor(0, 0, 0), nwStatusShape.SQUARE, QIcon(), 0)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
# Requires Python 3.10
|
||||
T_UpdateEntry = list[tuple[str | None, StatusEntry]]
|
||||
T_StatusKind = Literal["s", "i"]
|
||||
|
||||
|
||||
class NWStatus:
|
||||
|
||||
@@ -73,7 +78,7 @@ class NWStatus:
|
||||
|
||||
__slots__ = ("_store", "_default", "_prefix", "_height")
|
||||
|
||||
def __init__(self, prefix: Literal["s", "i"]) -> None:
|
||||
def __init__(self, prefix: T_StatusKind) -> None:
|
||||
self._store: dict[str, StatusEntry] = {}
|
||||
self._default = None
|
||||
self._prefix = prefix[:1]
|
||||
@@ -120,8 +125,8 @@ class NWStatus:
|
||||
|
||||
return key
|
||||
|
||||
def update(self, update: list[tuple[str | None, StatusEntry]]) -> None:
|
||||
"""Update the list of statuses, and from removed list."""
|
||||
def update(self, update: T_UpdateEntry) -> None:
|
||||
"""Update the list of statuses."""
|
||||
self._store.clear()
|
||||
for key, entry in update:
|
||||
self._store[self._checkKey(key)] = entry
|
||||
@@ -169,6 +174,20 @@ class NWStatus:
|
||||
"""Yield entries from the status icons."""
|
||||
yield from self._store.items()
|
||||
|
||||
def fromRaw(self, data: list[str]) -> StatusEntry | None:
|
||||
"""Create a StatusEntry from a list of three strings consisting
|
||||
of shape, colour, and name. This entry is not automatically
|
||||
added to the list of entries.
|
||||
"""
|
||||
try:
|
||||
shape = nwStatusShape[str(data[0])]
|
||||
color = QColor(str(data[1]))
|
||||
icon = NWStatus.createIcon(self._height, color, shape)
|
||||
return StatusEntry(simplified(data[2]), color, shape, icon)
|
||||
except Exception:
|
||||
logger.error("Could not parse entry %s", str(data))
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def createIcon(height: int, color: QColor, shape: nwStatusShape) -> QIcon:
|
||||
"""Generate an icon for a status label."""
|
||||
@@ -176,7 +195,7 @@ class NWStatus:
|
||||
pixmap.fill(QtTransparent)
|
||||
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QtPaintAnitAlias)
|
||||
painter.setRenderHint(QtPaintAntiAlias)
|
||||
painter.fillPath(_SHAPES.getShape(shape), color)
|
||||
painter.end()
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ File History:
|
||||
Created: 2022-11-01 [2.0rc2] NWStorage
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2022 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -235,7 +235,6 @@ class NWStorage:
|
||||
if child.is_dir() and child.name.startswith("data_"):
|
||||
legacy.legacyDataFolder(basePath, child)
|
||||
|
||||
self._writeLockFile()
|
||||
self._ready = True
|
||||
|
||||
return NWStorageOpen.READY
|
||||
@@ -249,6 +248,12 @@ class NWStorage:
|
||||
return True
|
||||
return True
|
||||
|
||||
def lockSession(self) -> None:
|
||||
"""Lock the session when the project is successfully opened."""
|
||||
if self._ready:
|
||||
self._writeLockFile()
|
||||
return
|
||||
|
||||
def closeSession(self) -> None:
|
||||
"""Run tasks related to closing the session."""
|
||||
self._clearLockFile()
|
||||
|
||||
@@ -1,530 +0,0 @@
|
||||
"""
|
||||
novelWriter – HTML Text Converter
|
||||
=================================
|
||||
|
||||
File History:
|
||||
Created: 2019-05-07 [0.0.1] ToHtml
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
|
||||
from novelwriter.common import formatTimeStamp
|
||||
from novelwriter.constants import nwHeadFmt, nwHtmlUnicode, nwKeyWords, nwLabels
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.core.tokenizer import T_Formats, Tokenizer, stripEscape
|
||||
from novelwriter.types import FONT_STYLE, FONT_WEIGHTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Each opener tag, with the id of its corresponding closer and tag format
|
||||
HTML_OPENER: dict[int, tuple[int, str]] = {
|
||||
Tokenizer.FMT_B_B: (Tokenizer.FMT_B_E, "<strong>"),
|
||||
Tokenizer.FMT_I_B: (Tokenizer.FMT_I_E, "<em>"),
|
||||
Tokenizer.FMT_D_B: (Tokenizer.FMT_D_E, "<del>"),
|
||||
Tokenizer.FMT_U_B: (Tokenizer.FMT_U_E, "<span style='text-decoration: underline;'>"),
|
||||
Tokenizer.FMT_M_B: (Tokenizer.FMT_M_E, "<mark>"),
|
||||
Tokenizer.FMT_SUP_B: (Tokenizer.FMT_SUP_E, "<sup>"),
|
||||
Tokenizer.FMT_SUB_B: (Tokenizer.FMT_SUB_E, "<sub>"),
|
||||
Tokenizer.FMT_DL_B: (Tokenizer.FMT_DL_E, "<span class='dialog'>"),
|
||||
Tokenizer.FMT_ADL_B: (Tokenizer.FMT_ADL_E, "<span class='altdialog'>"),
|
||||
}
|
||||
|
||||
# Each closer tag, with the id of its corresponding opener and tag format
|
||||
HTML_CLOSER: dict[int, tuple[int, str]] = {
|
||||
Tokenizer.FMT_B_E: (Tokenizer.FMT_B_B, "</strong>"),
|
||||
Tokenizer.FMT_I_E: (Tokenizer.FMT_I_B, "</em>"),
|
||||
Tokenizer.FMT_D_E: (Tokenizer.FMT_D_B, "</del>"),
|
||||
Tokenizer.FMT_U_E: (Tokenizer.FMT_U_B, "</span>"),
|
||||
Tokenizer.FMT_M_E: (Tokenizer.FMT_M_B, "</mark>"),
|
||||
Tokenizer.FMT_SUP_E: (Tokenizer.FMT_SUP_B, "</sup>"),
|
||||
Tokenizer.FMT_SUB_E: (Tokenizer.FMT_SUB_B, "</sub>"),
|
||||
Tokenizer.FMT_DL_E: (Tokenizer.FMT_DL_B, "</span>"),
|
||||
Tokenizer.FMT_ADL_E: (Tokenizer.FMT_ADL_B, "</span>"),
|
||||
}
|
||||
|
||||
# Empty HTML tag record
|
||||
HTML_NONE = (0, "")
|
||||
|
||||
|
||||
class ToHtml(Tokenizer):
|
||||
"""Core: HTML Document Writer
|
||||
|
||||
Extend the Tokenizer class to writer HTML output. This class is
|
||||
also used by the Document Viewer, and Manuscript Build Preview.
|
||||
"""
|
||||
|
||||
def __init__(self, project: NWProject) -> None:
|
||||
super().__init__(project)
|
||||
|
||||
self._cssStyles = True
|
||||
self._fullHTML: list[str] = []
|
||||
|
||||
# Internals
|
||||
self._trMap = {}
|
||||
self._usedNotes: dict[str, int] = {}
|
||||
self.setReplaceUnicode(False)
|
||||
|
||||
return
|
||||
|
||||
##
|
||||
# Properties
|
||||
##
|
||||
|
||||
@property
|
||||
def fullHTML(self) -> list[str]:
|
||||
return self._fullHTML
|
||||
|
||||
##
|
||||
# Setters
|
||||
##
|
||||
|
||||
def setStyles(self, cssStyles: bool) -> None:
|
||||
"""Enable or disable CSS styling. Some elements may still have
|
||||
class tags.
|
||||
"""
|
||||
self._cssStyles = cssStyles
|
||||
return
|
||||
|
||||
def setReplaceUnicode(self, doReplace: bool) -> None:
|
||||
"""Set the translation map to either minimal or full unicode for
|
||||
html entities replacement.
|
||||
"""
|
||||
# Control characters must always be replaced
|
||||
# Angle brackets are replaced later as they are also used in
|
||||
# formatting codes
|
||||
self._trMap = str.maketrans({"&": "&"})
|
||||
if doReplace:
|
||||
# Extend to all relevant Unicode characters
|
||||
self._trMap.update(str.maketrans(nwHtmlUnicode.U_TO_H))
|
||||
return
|
||||
|
||||
##
|
||||
# Class Methods
|
||||
##
|
||||
|
||||
def getFullResultSize(self) -> int:
|
||||
"""Return the size of the full HTML result."""
|
||||
return sum(len(x) for x in self._fullHTML)
|
||||
|
||||
def doPreProcessing(self) -> None:
|
||||
"""Extend the auto-replace to also properly encode some unicode
|
||||
characters into their respective HTML entities.
|
||||
"""
|
||||
super().doPreProcessing()
|
||||
self._text = self._text.translate(self._trMap)
|
||||
return
|
||||
|
||||
def doConvert(self) -> None:
|
||||
"""Convert the list of text tokens into an HTML document."""
|
||||
self._result = ""
|
||||
|
||||
if self._isNovel:
|
||||
# For story files, we bump the titles one level up
|
||||
h1Cl = " class='title'"
|
||||
h1 = "h1"
|
||||
h2 = "h1"
|
||||
h3 = "h2"
|
||||
h4 = "h3"
|
||||
else:
|
||||
h1Cl = ""
|
||||
h1 = "h1"
|
||||
h2 = "h2"
|
||||
h3 = "h3"
|
||||
h4 = "h4"
|
||||
|
||||
lines = []
|
||||
tHandle = self._handle
|
||||
|
||||
for tType, nHead, tText, tFormat, tStyle in self._tokens:
|
||||
|
||||
# Replace < and > with HTML entities
|
||||
if tFormat:
|
||||
# If we have formatting, we must recompute the locations
|
||||
cText = []
|
||||
i = 0
|
||||
for c in tText:
|
||||
if c == "<":
|
||||
cText.append("<")
|
||||
tFormat = [(p + 3 if p > i else p, f, k) for p, f, k in tFormat]
|
||||
i += 4
|
||||
elif c == ">":
|
||||
cText.append(">")
|
||||
tFormat = [(p + 3 if p > i else p, f, k) for p, f, k in tFormat]
|
||||
i += 4
|
||||
else:
|
||||
cText.append(c)
|
||||
i += 1
|
||||
tText = "".join(cText)
|
||||
else:
|
||||
# If we don't have formatting, we can do a plain replace
|
||||
tText = tText.replace("<", "<").replace(">", ">")
|
||||
|
||||
# Styles
|
||||
aStyle = []
|
||||
if tStyle is not None and self._cssStyles:
|
||||
if tStyle & self.A_LEFT:
|
||||
aStyle.append("text-align: left;")
|
||||
elif tStyle & self.A_RIGHT:
|
||||
aStyle.append("text-align: right;")
|
||||
elif tStyle & self.A_CENTRE:
|
||||
aStyle.append("text-align: center;")
|
||||
elif tStyle & self.A_JUSTIFY:
|
||||
aStyle.append("text-align: justify;")
|
||||
|
||||
if tStyle & self.A_PBB:
|
||||
aStyle.append("page-break-before: always;")
|
||||
if tStyle & self.A_PBA:
|
||||
aStyle.append("page-break-after: always;")
|
||||
|
||||
if tStyle & self.A_Z_BTMMRG:
|
||||
aStyle.append("margin-bottom: 0;")
|
||||
if tStyle & self.A_Z_TOPMRG:
|
||||
aStyle.append("margin-top: 0;")
|
||||
|
||||
if tStyle & self.A_IND_L:
|
||||
aStyle.append(f"margin-left: {self._blockIndent:.2f}em;")
|
||||
if tStyle & self.A_IND_R:
|
||||
aStyle.append(f"margin-right: {self._blockIndent:.2f}em;")
|
||||
if tStyle & self.A_IND_T:
|
||||
aStyle.append(f"text-indent: {self._firstWidth:.2f}em;")
|
||||
|
||||
if aStyle:
|
||||
stVals = " ".join(aStyle)
|
||||
hStyle = f" style='{stVals}'"
|
||||
else:
|
||||
hStyle = ""
|
||||
|
||||
if self._linkHeadings and tHandle:
|
||||
aNm = f"<a name='{tHandle}:T{nHead:04d}'></a>"
|
||||
else:
|
||||
aNm = ""
|
||||
|
||||
# Process Text Type
|
||||
if tType == self.T_TEXT:
|
||||
lines.append(f"<p{hStyle}>{self._formatText(tText, tFormat)}</p>\n")
|
||||
|
||||
elif tType == self.T_TITLE:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "<br>")
|
||||
lines.append(f"<h1 class='title'{hStyle}>{aNm}{tHead}</h1>\n")
|
||||
|
||||
elif tType == self.T_HEAD1:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "<br>")
|
||||
lines.append(f"<{h1}{h1Cl}{hStyle}>{aNm}{tHead}</{h1}>\n")
|
||||
|
||||
elif tType == self.T_HEAD2:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "<br>")
|
||||
lines.append(f"<{h2}{hStyle}>{aNm}{tHead}</{h2}>\n")
|
||||
|
||||
elif tType == self.T_HEAD3:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "<br>")
|
||||
lines.append(f"<{h3}{hStyle}>{aNm}{tHead}</{h3}>\n")
|
||||
|
||||
elif tType == self.T_HEAD4:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "<br>")
|
||||
lines.append(f"<{h4}{hStyle}>{aNm}{tHead}</{h4}>\n")
|
||||
|
||||
elif tType == self.T_SEP:
|
||||
lines.append(f"<p class='sep'{hStyle}>{tText}</p>\n")
|
||||
|
||||
elif tType == self.T_SKIP:
|
||||
lines.append(f"<p class='skip'{hStyle}> </p>\n")
|
||||
|
||||
elif tType == self.T_SYNOPSIS and self._doSynopsis:
|
||||
lines.append(self._formatSynopsis(self._formatText(tText, tFormat), True))
|
||||
|
||||
elif tType == self.T_SHORT and self._doSynopsis:
|
||||
lines.append(self._formatSynopsis(self._formatText(tText, tFormat), False))
|
||||
|
||||
elif tType == self.T_COMMENT and self._doComments:
|
||||
lines.append(self._formatComments(self._formatText(tText, tFormat)))
|
||||
|
||||
elif tType == self.T_KEYWORD and self._doKeywords:
|
||||
tag, text = self._formatKeywords(tText)
|
||||
kClass = f" class='meta meta-{tag}'" if tag else ""
|
||||
tTemp = f"<p{kClass}{hStyle}>{text}</p>\n"
|
||||
lines.append(tTemp)
|
||||
|
||||
self._result = "".join(lines)
|
||||
self._fullHTML.append(self._result)
|
||||
|
||||
return
|
||||
|
||||
def appendFootnotes(self) -> None:
|
||||
"""Append the footnotes in the buffer."""
|
||||
if self._usedNotes:
|
||||
footnotes = self._localLookup("Footnotes")
|
||||
|
||||
lines = []
|
||||
lines.append(f"<h3>{footnotes}</h3>\n")
|
||||
lines.append("<ol>\n")
|
||||
for key, index in self._usedNotes.items():
|
||||
if content := self._footnotes.get(key):
|
||||
text = self._formatText(*content)
|
||||
lines.append(f"<li id='footnote_{index}'><p>{text}</p></li>\n")
|
||||
lines.append("</ol>\n")
|
||||
|
||||
result = "".join(lines)
|
||||
self._result += result
|
||||
self._fullHTML.append(result)
|
||||
|
||||
return
|
||||
|
||||
def saveHtml5(self, path: str | Path) -> None:
|
||||
"""Save the data to an HTML file."""
|
||||
with open(path, mode="w", encoding="utf-8") as fObj:
|
||||
fObj.write((
|
||||
"<!DOCTYPE html>\n"
|
||||
"<html>\n"
|
||||
"<head>\n"
|
||||
"<meta charset='utf-8'>\n"
|
||||
"<title>{title:s}</title>\n"
|
||||
"</head>\n"
|
||||
"<style>\n"
|
||||
"{style:s}\n"
|
||||
"</style>\n"
|
||||
"<body>\n"
|
||||
"<article>\n"
|
||||
"{body:s}\n"
|
||||
"</article>\n"
|
||||
"</body>\n"
|
||||
"</html>\n"
|
||||
).format(
|
||||
title=self._project.data.name,
|
||||
style="\n".join(self.getStyleSheet()),
|
||||
body=("".join(self._fullHTML)).replace("\t", "	").rstrip(),
|
||||
))
|
||||
logger.info("Wrote file: %s", path)
|
||||
return
|
||||
|
||||
def saveHtmlJson(self, path: str | Path) -> None:
|
||||
"""Save the data to a JSON file."""
|
||||
timeStamp = time()
|
||||
data = {
|
||||
"meta": {
|
||||
"projectName": self._project.data.name,
|
||||
"novelAuthor": self._project.data.author,
|
||||
"buildTime": int(timeStamp),
|
||||
"buildTimeStr": formatTimeStamp(timeStamp),
|
||||
},
|
||||
"text": {
|
||||
"css": self.getStyleSheet(),
|
||||
"html": [t.replace("\t", "	").rstrip().split("\n") for t in self.fullHTML],
|
||||
}
|
||||
}
|
||||
with open(path, mode="w", encoding="utf-8") as fObj:
|
||||
json.dump(data, fObj, indent=2)
|
||||
logger.info("Wrote file: %s", path)
|
||||
return
|
||||
|
||||
def replaceTabs(self, nSpaces: int = 8, spaceChar: str = " ") -> None:
|
||||
"""Replace tabs with spaces in the html."""
|
||||
htmlText = []
|
||||
tabSpace = spaceChar*nSpaces
|
||||
for aLine in self._fullHTML:
|
||||
htmlText.append(aLine.replace("\t", tabSpace))
|
||||
|
||||
self._fullHTML = htmlText
|
||||
return
|
||||
|
||||
def getStyleSheet(self) -> list[str]:
|
||||
"""Generate a stylesheet for the current settings."""
|
||||
if not self._cssStyles:
|
||||
return []
|
||||
|
||||
mScale = self._lineHeight/1.15
|
||||
|
||||
styles = []
|
||||
font = self._textFont
|
||||
styles.append((
|
||||
"body {{"
|
||||
"font-family: '{0:s}'; font-size: {1:d}pt; "
|
||||
"font-weight: {2:d}; font-style: {3:s};"
|
||||
"}}"
|
||||
).format(
|
||||
font.family(), font.pointSize(),
|
||||
FONT_WEIGHTS.get(font.weight(), 400),
|
||||
FONT_STYLE.get(font.style(), "normal"),
|
||||
))
|
||||
styles.append((
|
||||
"p {{"
|
||||
"text-align: {0}; line-height: {1:d}%; "
|
||||
"margin-top: {2:.2f}em; margin-bottom: {3:.2f}em;"
|
||||
"}}"
|
||||
).format(
|
||||
"justify" if self._doJustify else "left",
|
||||
round(100 * self._lineHeight),
|
||||
mScale * self._marginText[0],
|
||||
mScale * self._marginText[1],
|
||||
))
|
||||
styles.append((
|
||||
"h1 {{"
|
||||
"color: rgb(66, 113, 174); "
|
||||
"page-break-after: avoid; "
|
||||
"margin-top: {0:.2f}em; "
|
||||
"margin-bottom: {1:.2f}em;"
|
||||
"}}"
|
||||
).format(
|
||||
mScale * self._marginHead1[0], mScale * self._marginHead1[1]
|
||||
))
|
||||
styles.append((
|
||||
"h2 {{"
|
||||
"color: rgb(66, 113, 174); "
|
||||
"page-break-after: avoid; "
|
||||
"margin-top: {0:.2f}em; "
|
||||
"margin-bottom: {1:.2f}em;"
|
||||
"}}"
|
||||
).format(
|
||||
mScale * self._marginHead2[0], mScale * self._marginHead2[1]
|
||||
))
|
||||
styles.append((
|
||||
"h3 {{"
|
||||
"color: rgb(50, 50, 50); "
|
||||
"page-break-after: avoid; "
|
||||
"margin-top: {0:.2f}em; "
|
||||
"margin-bottom: {1:.2f}em;"
|
||||
"}}"
|
||||
).format(
|
||||
mScale * self._marginHead3[0], mScale * self._marginHead3[1]
|
||||
))
|
||||
styles.append((
|
||||
"h4 {{"
|
||||
"color: rgb(50, 50, 50); "
|
||||
"page-break-after: avoid; "
|
||||
"margin-top: {0:.2f}em; "
|
||||
"margin-bottom: {1:.2f}em;"
|
||||
"}}"
|
||||
).format(
|
||||
mScale * self._marginHead4[0], mScale * self._marginHead4[1]
|
||||
))
|
||||
styles.append((
|
||||
".title {{"
|
||||
"font-size: 2.5em; "
|
||||
"margin-top: {0:.2f}em; "
|
||||
"margin-bottom: {1:.2f}em;"
|
||||
"}}"
|
||||
).format(
|
||||
mScale * self._marginTitle[0], mScale * self._marginTitle[1]
|
||||
))
|
||||
styles.append((
|
||||
".sep, .skip {{"
|
||||
"text-align: center; "
|
||||
"margin-top: {0:.2f}em; "
|
||||
"margin-bottom: {1:.2f}em;"
|
||||
"}}"
|
||||
).format(
|
||||
mScale, mScale
|
||||
))
|
||||
|
||||
styles.append("a {color: rgb(66, 113, 174);}")
|
||||
styles.append("mark {background: rgb(255, 255, 166);}")
|
||||
styles.append(".keyword {color: rgb(245, 135, 31); font-weight: bold;}")
|
||||
styles.append(".break {text-align: left;}")
|
||||
styles.append(".synopsis {font-style: italic;}")
|
||||
styles.append(".comment {font-style: italic; color: rgb(100, 100, 100);}")
|
||||
styles.append(".dialog {color: rgb(66, 113, 174);}")
|
||||
styles.append(".altdialog {color: rgb(129, 55, 9);}")
|
||||
|
||||
return styles
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _formatText(self, text: str, tFmt: T_Formats) -> str:
|
||||
"""Apply formatting tags to text."""
|
||||
temp = text
|
||||
|
||||
# Build a list of all html tags that need to be inserted in the text.
|
||||
# This is done in the forward direction, and a tag is only opened if it
|
||||
# isn't already open, and only closed if it has previously been opened.
|
||||
tags: list[tuple[int, str]] = []
|
||||
state = dict.fromkeys(HTML_OPENER, False)
|
||||
for pos, fmt, data in tFmt:
|
||||
if m := HTML_OPENER.get(fmt):
|
||||
if not state.get(fmt, True):
|
||||
tags.append((pos, m[1]))
|
||||
state[fmt] = True
|
||||
elif m := HTML_CLOSER.get(fmt):
|
||||
if state.get(m[0], False):
|
||||
tags.append((pos, m[1]))
|
||||
state[m[0]] = False
|
||||
elif fmt == self.FMT_FNOTE:
|
||||
if data in self._footnotes:
|
||||
index = len(self._usedNotes) + 1
|
||||
self._usedNotes[data] = index
|
||||
tags.append((pos, f"<sup><a href='#footnote_{index}'>{index}</a></sup>"))
|
||||
else:
|
||||
tags.append((pos, "<sup>ERR</sup>"))
|
||||
|
||||
# Check all format types and close any tag that is still open. This
|
||||
# ensures that unclosed tags don't spill over to the next paragraph.
|
||||
end = len(text)
|
||||
for opener, active in state.items():
|
||||
if active:
|
||||
closer = HTML_OPENER.get(opener, HTML_NONE)[0]
|
||||
tags.append((end, HTML_CLOSER.get(closer, HTML_NONE)[1]))
|
||||
|
||||
# Insert all tags at their correct position, starting from the back.
|
||||
# The reverse order ensures that the positions are not shifted while we
|
||||
# insert tags.
|
||||
for pos, tag in reversed(tags):
|
||||
temp = f"{temp[:pos]}{tag}{temp[pos:]}"
|
||||
|
||||
# Replace all line breaks with proper HTML break tags
|
||||
temp = temp.replace("\n", "<br>")
|
||||
|
||||
return stripEscape(temp)
|
||||
|
||||
def _formatSynopsis(self, text: str, synopsis: bool) -> str:
|
||||
"""Apply HTML formatting to synopsis."""
|
||||
if synopsis:
|
||||
sSynop = self._localLookup("Synopsis")
|
||||
else:
|
||||
sSynop = self._localLookup("Short Description")
|
||||
return f"<p class='synopsis'><strong>{sSynop}:</strong> {text}</p>\n"
|
||||
|
||||
def _formatComments(self, text: str) -> str:
|
||||
"""Apply HTML formatting to comments."""
|
||||
sComm = self._localLookup("Comment")
|
||||
return f"<p class='comment'><strong>{sComm}:</strong> {text}</p>\n"
|
||||
|
||||
def _formatKeywords(self, text: str) -> tuple[str, str]:
|
||||
"""Apply HTML formatting to keywords."""
|
||||
valid, bits, _ = self._project.index.scanThis("@"+text)
|
||||
if not valid or not bits or bits[0] not in nwLabels.KEY_NAME:
|
||||
return "", ""
|
||||
|
||||
result = f"<span class='keyword'>{self._localLookup(nwLabels.KEY_NAME[bits[0]])}:</span> "
|
||||
if len(bits) > 1:
|
||||
if bits[0] == nwKeyWords.TAG_KEY:
|
||||
one, two = self._project.index.parseValue(bits[1])
|
||||
result += f"<a class='tag' name='tag_{one}'>{one}</a>"
|
||||
if two:
|
||||
result += f" | <span class='optional'>{two}</a>"
|
||||
else:
|
||||
result += ", ".join(
|
||||
f"<a class='tag' href='#tag_{t}'>{t}</a>" for t in bits[1:]
|
||||
)
|
||||
|
||||
return bits[0][1:], result
|
||||
@@ -1,252 +0,0 @@
|
||||
"""
|
||||
novelWriter – Markdown Text Converter
|
||||
=====================================
|
||||
|
||||
File History:
|
||||
Created: 2021-02-06 [1.2b1] ToMarkdown
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from novelwriter.constants import nwHeadFmt, nwLabels, nwUnicode
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.core.tokenizer import T_Formats, Tokenizer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Standard Markdown
|
||||
STD_MD = {
|
||||
Tokenizer.FMT_B_B: "**",
|
||||
Tokenizer.FMT_B_E: "**",
|
||||
Tokenizer.FMT_I_B: "_",
|
||||
Tokenizer.FMT_I_E: "_",
|
||||
Tokenizer.FMT_D_B: "",
|
||||
Tokenizer.FMT_D_E: "",
|
||||
Tokenizer.FMT_U_B: "",
|
||||
Tokenizer.FMT_U_E: "",
|
||||
Tokenizer.FMT_M_B: "",
|
||||
Tokenizer.FMT_M_E: "",
|
||||
Tokenizer.FMT_SUP_B: "",
|
||||
Tokenizer.FMT_SUP_E: "",
|
||||
Tokenizer.FMT_SUB_B: "",
|
||||
Tokenizer.FMT_SUB_E: "",
|
||||
Tokenizer.FMT_STRIP: "",
|
||||
}
|
||||
|
||||
# Extended Markdown
|
||||
EXT_MD = {
|
||||
Tokenizer.FMT_B_B: "**",
|
||||
Tokenizer.FMT_B_E: "**",
|
||||
Tokenizer.FMT_I_B: "_",
|
||||
Tokenizer.FMT_I_E: "_",
|
||||
Tokenizer.FMT_D_B: "~~",
|
||||
Tokenizer.FMT_D_E: "~~",
|
||||
Tokenizer.FMT_U_B: "",
|
||||
Tokenizer.FMT_U_E: "",
|
||||
Tokenizer.FMT_M_B: "==",
|
||||
Tokenizer.FMT_M_E: "==",
|
||||
Tokenizer.FMT_SUP_B: "^",
|
||||
Tokenizer.FMT_SUP_E: "^",
|
||||
Tokenizer.FMT_SUB_B: "~",
|
||||
Tokenizer.FMT_SUB_E: "~",
|
||||
Tokenizer.FMT_STRIP: "",
|
||||
}
|
||||
|
||||
|
||||
class ToMarkdown(Tokenizer):
|
||||
"""Core: Markdown Document Writer
|
||||
|
||||
Extend the Tokenizer class to writer Markdown output. It supports
|
||||
both Standard Markdown and Extended Markdown. The class also
|
||||
supports concatenating novelWriter markup files.
|
||||
"""
|
||||
|
||||
def __init__(self, project: NWProject) -> None:
|
||||
super().__init__(project)
|
||||
self._fullMD: list[str] = []
|
||||
self._usedNotes: dict[str, int] = {}
|
||||
self._extended = True
|
||||
return
|
||||
|
||||
##
|
||||
# Properties
|
||||
##
|
||||
|
||||
@property
|
||||
def fullMD(self) -> list[str]:
|
||||
"""Return the markdown as a list."""
|
||||
return self._fullMD
|
||||
|
||||
##
|
||||
# Setters
|
||||
##
|
||||
|
||||
def setExtendedMarkdown(self, state: bool) -> None:
|
||||
"""Set the converter to use Extended Markdown formatting."""
|
||||
self._extended = state
|
||||
return
|
||||
|
||||
##
|
||||
# Class Methods
|
||||
##
|
||||
|
||||
def getFullResultSize(self) -> int:
|
||||
"""Return the size of the full Markdown result."""
|
||||
return sum(len(x) for x in self._fullMD)
|
||||
|
||||
def doConvert(self) -> None:
|
||||
"""Convert the list of text tokens into a Markdown document."""
|
||||
self._result = ""
|
||||
|
||||
if self._extended:
|
||||
mTags = EXT_MD
|
||||
cSkip = nwUnicode.U_MMSP
|
||||
else:
|
||||
mTags = STD_MD
|
||||
cSkip = ""
|
||||
|
||||
lines = []
|
||||
for tType, _, tText, tFormat, tStyle in self._tokens:
|
||||
|
||||
if tType == self.T_TEXT:
|
||||
tTemp = self._formatText(tText, tFormat, mTags).replace("\n", " \n")
|
||||
lines.append(f"{tTemp}\n\n")
|
||||
|
||||
elif tType == self.T_TITLE:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "\n")
|
||||
lines.append(f"# {tHead}\n\n")
|
||||
|
||||
elif tType == self.T_HEAD1:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "\n")
|
||||
lines.append(f"# {tHead}\n\n")
|
||||
|
||||
elif tType == self.T_HEAD2:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "\n")
|
||||
lines.append(f"## {tHead}\n\n")
|
||||
|
||||
elif tType == self.T_HEAD3:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "\n")
|
||||
lines.append(f"### {tHead}\n\n")
|
||||
|
||||
elif tType == self.T_HEAD4:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "\n")
|
||||
lines.append(f"#### {tHead}\n\n")
|
||||
|
||||
elif tType == self.T_SEP:
|
||||
lines.append(f"{tText}\n\n")
|
||||
|
||||
elif tType == self.T_SKIP:
|
||||
lines.append(f"{cSkip}\n\n")
|
||||
|
||||
elif tType == self.T_SYNOPSIS and self._doSynopsis:
|
||||
label = self._localLookup("Synopsis")
|
||||
lines.append(f"**{label}:** {self._formatText(tText, tFormat, mTags)}\n\n")
|
||||
|
||||
elif tType == self.T_SHORT and self._doSynopsis:
|
||||
label = self._localLookup("Short Description")
|
||||
lines.append(f"**{label}:** {self._formatText(tText, tFormat, mTags)}\n\n")
|
||||
|
||||
elif tType == self.T_COMMENT and self._doComments:
|
||||
label = self._localLookup("Comment")
|
||||
lines.append(f"**{label}:** {self._formatText(tText, tFormat, mTags)}\n\n")
|
||||
|
||||
elif tType == self.T_KEYWORD and self._doKeywords:
|
||||
lines.append(self._formatKeywords(tText, tStyle))
|
||||
|
||||
self._result = "".join(lines)
|
||||
self._fullMD.append(self._result)
|
||||
|
||||
return
|
||||
|
||||
def appendFootnotes(self) -> None:
|
||||
"""Append the footnotes in the buffer."""
|
||||
if self._usedNotes:
|
||||
tags = EXT_MD if self._extended else STD_MD
|
||||
footnotes = self._localLookup("Footnotes")
|
||||
|
||||
lines = []
|
||||
lines.append(f"### {footnotes}\n\n")
|
||||
for key, index in self._usedNotes.items():
|
||||
if content := self._footnotes.get(key):
|
||||
marker = f"{index}. "
|
||||
text = self._formatText(*content, tags)
|
||||
lines.append(f"{marker}{text}\n")
|
||||
lines.append("\n")
|
||||
|
||||
result = "".join(lines)
|
||||
self._result += result
|
||||
self._fullMD.append(result)
|
||||
|
||||
return
|
||||
|
||||
def saveMarkdown(self, path: str | Path) -> None:
|
||||
"""Save the data to a plain text file."""
|
||||
with open(path, mode="w", encoding="utf-8") as outFile:
|
||||
outFile.write("".join(self._fullMD))
|
||||
logger.info("Wrote file: %s", path)
|
||||
return
|
||||
|
||||
def replaceTabs(self, nSpaces: int = 8, spaceChar: str = " ") -> None:
|
||||
"""Replace tabs with spaces."""
|
||||
spaces = spaceChar*nSpaces
|
||||
self._fullMD = [p.replace("\t", spaces) for p in self._fullMD]
|
||||
if self._keepMD:
|
||||
self._markdown = [p.replace("\t", spaces) for p in self._markdown]
|
||||
return
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _formatText(self, text: str, tFmt: T_Formats, tags: dict[int, str]) -> str:
|
||||
"""Apply formatting tags to text."""
|
||||
temp = text
|
||||
for pos, fmt, data in reversed(tFmt):
|
||||
md = ""
|
||||
if fmt == self.FMT_FNOTE:
|
||||
if data in self._footnotes:
|
||||
index = len(self._usedNotes) + 1
|
||||
self._usedNotes[data] = index
|
||||
md = f"[{index}]"
|
||||
else:
|
||||
md = "[ERR]"
|
||||
else:
|
||||
md = tags.get(fmt, "")
|
||||
temp = f"{temp[:pos]}{md}{temp[pos:]}"
|
||||
return temp
|
||||
|
||||
def _formatKeywords(self, text: str, style: int) -> str:
|
||||
"""Apply Markdown formatting to keywords."""
|
||||
valid, bits, _ = self._project.index.scanThis("@"+text)
|
||||
if not valid or not bits:
|
||||
return ""
|
||||
|
||||
result = ""
|
||||
if bits[0] in nwLabels.KEY_NAME:
|
||||
result += f"**{self._localLookup(nwLabels.KEY_NAME[bits[0]])}:** "
|
||||
if len(bits) > 1:
|
||||
result += ", ".join(bits[1:])
|
||||
|
||||
result += " \n" if style & self.A_Z_BTMMRG else "\n\n"
|
||||
|
||||
return result
|
||||
@@ -1,419 +0,0 @@
|
||||
"""
|
||||
novelWriter – QTextDocument Converter
|
||||
=====================================
|
||||
|
||||
File History:
|
||||
Created: 2024-05-21 [2.5b1] ToQTextDocument
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from PyQt5.QtGui import (
|
||||
QColor, QFont, QFontMetricsF, QTextBlockFormat, QTextCharFormat,
|
||||
QTextCursor, QTextDocument
|
||||
)
|
||||
|
||||
from novelwriter.constants import nwHeaders, nwHeadFmt, nwKeyWords, nwLabels, nwUnicode
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.core.tokenizer import T_Formats, Tokenizer
|
||||
from novelwriter.types import (
|
||||
QtAlignAbsolute, QtAlignCenter, QtAlignJustify, QtAlignLeft, QtAlignRight,
|
||||
QtBlack, QtPageBreakAfter, QtPageBreakBefore, QtTransparent,
|
||||
QtVAlignNormal, QtVAlignSub, QtVAlignSuper
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T_TextStyle = tuple[QTextBlockFormat, QTextCharFormat]
|
||||
|
||||
|
||||
class TextDocumentTheme:
|
||||
text: QColor = QtBlack
|
||||
highlight: QColor = QtTransparent
|
||||
head: QColor = QtBlack
|
||||
comment: QColor = QtBlack
|
||||
note: QColor = QtBlack
|
||||
code: QColor = QtBlack
|
||||
modifier: QColor = QtBlack
|
||||
keyword: QColor = QtBlack
|
||||
tag: QColor = QtBlack
|
||||
optional: QColor = QtBlack
|
||||
dialog: QColor = QtBlack
|
||||
altdialog: QColor = QtBlack
|
||||
|
||||
|
||||
def newBlock(cursor: QTextCursor, bFmt: QTextBlockFormat) -> None:
|
||||
if cursor.position() > 0:
|
||||
cursor.insertBlock(bFmt)
|
||||
else:
|
||||
cursor.setBlockFormat(bFmt)
|
||||
|
||||
|
||||
class ToQTextDocument(Tokenizer):
|
||||
"""Core: QTextDocument Writer
|
||||
|
||||
Extend the Tokenizer class to generate a QTextDocument output. This
|
||||
is intended for usage in the document viewer and build tool preview.
|
||||
"""
|
||||
|
||||
def __init__(self, project: NWProject) -> None:
|
||||
super().__init__(project)
|
||||
self._document = QTextDocument()
|
||||
self._document.setUndoRedoEnabled(False)
|
||||
self._document.setDocumentMargin(0)
|
||||
|
||||
self._theme = TextDocumentTheme()
|
||||
self._styles: dict[int, T_TextStyle] = {}
|
||||
self._usedNotes: dict[str, int] = {}
|
||||
|
||||
self._init = False
|
||||
self._bold = QFont.Weight.Bold
|
||||
self._normal = QFont.Weight.Normal
|
||||
|
||||
return
|
||||
|
||||
def initDocument(self, font: QFont, theme: TextDocumentTheme) -> None:
|
||||
"""Initialise all computed values of the document."""
|
||||
self._textFont = font
|
||||
self._theme = theme
|
||||
|
||||
self._document.setUndoRedoEnabled(False)
|
||||
self._document.blockSignals(True)
|
||||
self._document.clear()
|
||||
self._document.setDefaultFont(self._textFont)
|
||||
|
||||
qMetric = QFontMetricsF(self._textFont)
|
||||
mPx = qMetric.ascent() # 1 em in pixels
|
||||
fPt = self._textFont.pointSizeF()
|
||||
|
||||
# Scaled Sizes
|
||||
# ============
|
||||
|
||||
self._mHead = {
|
||||
self.T_TITLE: (mPx * self._marginTitle[0], mPx * self._marginTitle[1]),
|
||||
self.T_HEAD1: (mPx * self._marginHead1[0], mPx * self._marginHead1[1]),
|
||||
self.T_HEAD2: (mPx * self._marginHead2[0], mPx * self._marginHead2[1]),
|
||||
self.T_HEAD3: (mPx * self._marginHead3[0], mPx * self._marginHead3[1]),
|
||||
self.T_HEAD4: (mPx * self._marginHead4[0], mPx * self._marginHead4[1]),
|
||||
}
|
||||
|
||||
self._sHead = {
|
||||
self.T_TITLE: nwHeaders.H_SIZES.get(0, 1.0) * fPt,
|
||||
self.T_HEAD1: nwHeaders.H_SIZES.get(1, 1.0) * fPt,
|
||||
self.T_HEAD2: nwHeaders.H_SIZES.get(2, 1.0) * fPt,
|
||||
self.T_HEAD3: nwHeaders.H_SIZES.get(3, 1.0) * fPt,
|
||||
self.T_HEAD4: nwHeaders.H_SIZES.get(4, 1.0) * fPt,
|
||||
}
|
||||
|
||||
self._mText = (mPx * self._marginText[0], mPx * self._marginText[1])
|
||||
self._mMeta = (mPx * self._marginMeta[0], mPx * self._marginMeta[1])
|
||||
self._mSep = (mPx * self._marginSep[0], mPx * self._marginSep[1])
|
||||
|
||||
self._mIndent = mPx * 2.0
|
||||
self._tIndent = mPx * self._firstWidth
|
||||
|
||||
# Block Format
|
||||
# ============
|
||||
|
||||
self._blockFmt = QTextBlockFormat()
|
||||
self._blockFmt.setTopMargin(self._mText[0])
|
||||
self._blockFmt.setBottomMargin(self._mText[1])
|
||||
self._blockFmt.setAlignment(QtAlignJustify if self._doJustify else QtAlignAbsolute)
|
||||
self._blockFmt.setLineHeight(
|
||||
100*self._lineHeight, QTextBlockFormat.LineHeightTypes.ProportionalHeight
|
||||
)
|
||||
|
||||
# Character Formats
|
||||
# =================
|
||||
|
||||
self._cText = QTextCharFormat()
|
||||
self._cText.setBackground(QtTransparent)
|
||||
self._cText.setForeground(self._theme.text)
|
||||
|
||||
self._cHead = QTextCharFormat(self._cText)
|
||||
self._cHead.setForeground(self._theme.head)
|
||||
|
||||
self._cComment = QTextCharFormat(self._cText)
|
||||
self._cComment.setForeground(self._theme.comment)
|
||||
|
||||
self._cCommentMod = QTextCharFormat(self._cText)
|
||||
self._cCommentMod.setForeground(self._theme.comment)
|
||||
self._cCommentMod.setFontWeight(self._bold)
|
||||
|
||||
self._cNote = QTextCharFormat(self._cText)
|
||||
self._cNote.setForeground(self._theme.note)
|
||||
|
||||
self._cCode = QTextCharFormat(self._cText)
|
||||
self._cCode.setForeground(self._theme.code)
|
||||
|
||||
self._cModifier = QTextCharFormat(self._cText)
|
||||
self._cModifier.setForeground(self._theme.modifier)
|
||||
self._cModifier.setFontWeight(self._bold)
|
||||
|
||||
self._cKeyword = QTextCharFormat(self._cText)
|
||||
self._cKeyword.setForeground(self._theme.keyword)
|
||||
|
||||
self._cTag = QTextCharFormat(self._cText)
|
||||
self._cTag.setForeground(self._theme.tag)
|
||||
|
||||
self._cOptional = QTextCharFormat(self._cText)
|
||||
self._cOptional.setForeground(self._theme.optional)
|
||||
|
||||
self._init = True
|
||||
|
||||
return
|
||||
|
||||
##
|
||||
# Properties
|
||||
##
|
||||
|
||||
@property
|
||||
def document(self) -> QTextDocument:
|
||||
"""Return the document."""
|
||||
return self._document
|
||||
|
||||
##
|
||||
# Class Methods
|
||||
##
|
||||
|
||||
def doConvert(self) -> None:
|
||||
"""Write text tokens into the document."""
|
||||
if not self._init:
|
||||
return
|
||||
|
||||
self._document.blockSignals(True)
|
||||
cursor = QTextCursor(self._document)
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
|
||||
for tType, nHead, tText, tFormat, tStyle in self._tokens:
|
||||
|
||||
# Styles
|
||||
bFmt = QTextBlockFormat(self._blockFmt)
|
||||
if tStyle is not None:
|
||||
if tStyle & self.A_LEFT:
|
||||
bFmt.setAlignment(QtAlignLeft)
|
||||
elif tStyle & self.A_RIGHT:
|
||||
bFmt.setAlignment(QtAlignRight)
|
||||
elif tStyle & self.A_CENTRE:
|
||||
bFmt.setAlignment(QtAlignCenter)
|
||||
elif tStyle & self.A_JUSTIFY:
|
||||
bFmt.setAlignment(QtAlignJustify)
|
||||
|
||||
if tStyle & self.A_PBB:
|
||||
bFmt.setPageBreakPolicy(QtPageBreakBefore)
|
||||
if tStyle & self.A_PBA:
|
||||
bFmt.setPageBreakPolicy(QtPageBreakAfter)
|
||||
|
||||
if tStyle & self.A_Z_BTMMRG:
|
||||
bFmt.setBottomMargin(0.0)
|
||||
if tStyle & self.A_Z_TOPMRG:
|
||||
bFmt.setTopMargin(0.0)
|
||||
|
||||
if tStyle & self.A_IND_L:
|
||||
bFmt.setLeftMargin(self._mIndent)
|
||||
if tStyle & self.A_IND_R:
|
||||
bFmt.setRightMargin(self._mIndent)
|
||||
if tStyle & self.A_IND_T:
|
||||
bFmt.setTextIndent(self._tIndent)
|
||||
|
||||
if tType == self.T_TEXT:
|
||||
newBlock(cursor, bFmt)
|
||||
self._insertFragments(tText, tFormat, cursor, self._cText)
|
||||
|
||||
elif tType in self.L_HEADINGS:
|
||||
bFmt, cFmt = self._genHeadStyle(tType, nHead, bFmt)
|
||||
newBlock(cursor, bFmt)
|
||||
cursor.insertText(tText.replace(nwHeadFmt.BR, "\n"), cFmt)
|
||||
|
||||
elif tType == self.T_SEP:
|
||||
sFmt = QTextBlockFormat(bFmt)
|
||||
sFmt.setTopMargin(self._mSep[0])
|
||||
sFmt.setBottomMargin(self._mSep[1])
|
||||
newBlock(cursor, sFmt)
|
||||
cursor.insertText(tText, self._cText)
|
||||
|
||||
elif tType == self.T_SKIP:
|
||||
newBlock(cursor, bFmt)
|
||||
cursor.insertText(nwUnicode.U_NBSP, self._cText)
|
||||
|
||||
elif tType in self.L_SUMMARY and self._doSynopsis:
|
||||
newBlock(cursor, bFmt)
|
||||
modifier = self._localLookup(
|
||||
"Short Description" if tType == self.T_SHORT else "Synopsis"
|
||||
)
|
||||
cursor.insertText(f"{modifier}: ", self._cModifier)
|
||||
self._insertFragments(tText, tFormat, cursor, self._cNote)
|
||||
|
||||
elif tType == self.T_COMMENT and self._doComments:
|
||||
newBlock(cursor, bFmt)
|
||||
modifier = self._localLookup("Comment")
|
||||
cursor.insertText(f"{modifier}: ", self._cCommentMod)
|
||||
self._insertFragments(tText, tFormat, cursor, self._cComment)
|
||||
|
||||
elif tType == self.T_KEYWORD and self._doKeywords:
|
||||
newBlock(cursor, bFmt)
|
||||
self._insertKeywords(tText, cursor)
|
||||
|
||||
self._document.blockSignals(False)
|
||||
|
||||
return
|
||||
|
||||
def appendFootnotes(self) -> None:
|
||||
"""Append the footnotes in the buffer."""
|
||||
if self._usedNotes:
|
||||
self._document.blockSignals(True)
|
||||
|
||||
cursor = QTextCursor(self._document)
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
|
||||
bFmt, cFmt = self._genHeadStyle(self.T_HEAD4, -1, self._blockFmt)
|
||||
newBlock(cursor, bFmt)
|
||||
cursor.insertText(self._localLookup("Footnotes"), cFmt)
|
||||
|
||||
for key, index in self._usedNotes.items():
|
||||
if content := self._footnotes.get(key):
|
||||
cFmt = QTextCharFormat(self._cCode)
|
||||
cFmt.setAnchor(True)
|
||||
cFmt.setAnchorNames([f"footnote_{index}"])
|
||||
newBlock(cursor, self._blockFmt)
|
||||
cursor.insertText(f"{index}. ", cFmt)
|
||||
self._insertFragments(*content, cursor, self._cText)
|
||||
|
||||
self._document.blockSignals(False)
|
||||
|
||||
return
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _insertFragments(
|
||||
self, text: str, tFmt: T_Formats, cursor: QTextCursor, dFmt: QTextCharFormat
|
||||
) -> None:
|
||||
"""Apply formatting tags to text."""
|
||||
cFmt = QTextCharFormat(dFmt)
|
||||
start = 0
|
||||
temp = text.replace("\n", nwUnicode.U_LSEP)
|
||||
for pos, fmt, data in tFmt:
|
||||
|
||||
# Insert buffer with previous format
|
||||
cursor.insertText(temp[start:pos], cFmt)
|
||||
|
||||
# Construct next format
|
||||
if fmt == self.FMT_B_B:
|
||||
cFmt.setFontWeight(self._bold)
|
||||
elif fmt == self.FMT_B_E:
|
||||
cFmt.setFontWeight(self._normal)
|
||||
elif fmt == self.FMT_I_B:
|
||||
cFmt.setFontItalic(True)
|
||||
elif fmt == self.FMT_I_E:
|
||||
cFmt.setFontItalic(False)
|
||||
elif fmt == self.FMT_D_B:
|
||||
cFmt.setFontStrikeOut(True)
|
||||
elif fmt == self.FMT_D_E:
|
||||
cFmt.setFontStrikeOut(False)
|
||||
elif fmt == self.FMT_U_B:
|
||||
cFmt.setFontUnderline(True)
|
||||
elif fmt == self.FMT_U_E:
|
||||
cFmt.setFontUnderline(False)
|
||||
elif fmt == self.FMT_M_B:
|
||||
cFmt.setBackground(self._theme.highlight)
|
||||
elif fmt == self.FMT_M_E:
|
||||
cFmt.setBackground(QtTransparent)
|
||||
elif fmt == self.FMT_SUP_B:
|
||||
cFmt.setVerticalAlignment(QtVAlignSuper)
|
||||
elif fmt == self.FMT_SUP_E:
|
||||
cFmt.setVerticalAlignment(QtVAlignNormal)
|
||||
elif fmt == self.FMT_SUB_B:
|
||||
cFmt.setVerticalAlignment(QtVAlignSub)
|
||||
elif fmt == self.FMT_SUB_E:
|
||||
cFmt.setVerticalAlignment(QtVAlignNormal)
|
||||
elif fmt == self.FMT_DL_B:
|
||||
cFmt.setForeground(self._theme.dialog)
|
||||
elif fmt == self.FMT_DL_E:
|
||||
cFmt.setForeground(self._theme.text)
|
||||
elif fmt == self.FMT_ADL_B:
|
||||
cFmt.setForeground(self._theme.altdialog)
|
||||
elif fmt == self.FMT_ADL_E:
|
||||
cFmt.setForeground(self._theme.text)
|
||||
elif fmt == self.FMT_FNOTE:
|
||||
xFmt = QTextCharFormat(self._cCode)
|
||||
xFmt.setVerticalAlignment(QtVAlignSuper)
|
||||
if data in self._footnotes:
|
||||
index = len(self._usedNotes) + 1
|
||||
self._usedNotes[data] = index
|
||||
xFmt.setAnchor(True)
|
||||
xFmt.setAnchorHref(f"#footnote_{index}")
|
||||
xFmt.setFontUnderline(True)
|
||||
cursor.insertText(f"[{index}]", xFmt)
|
||||
else:
|
||||
cursor.insertText("[ERR]", cFmt)
|
||||
|
||||
# Move pos for next pass
|
||||
start = pos
|
||||
|
||||
# Insert whatever is left in the buffer
|
||||
cursor.insertText(temp[start:], cFmt)
|
||||
|
||||
return
|
||||
|
||||
def _insertKeywords(self, text: str, cursor: QTextCursor) -> None:
|
||||
"""Apply Markdown formatting to keywords."""
|
||||
valid, bits, _ = self._project.index.scanThis("@"+text)
|
||||
if valid and bits:
|
||||
key = f"{self._localLookup(nwLabels.KEY_NAME[bits[0]])}: "
|
||||
cursor.insertText(key, self._cKeyword)
|
||||
if (num := len(bits)) > 1:
|
||||
if bits[0] == nwKeyWords.TAG_KEY:
|
||||
one, two = self._project.index.parseValue(bits[1])
|
||||
cFmt = QTextCharFormat(self._cTag)
|
||||
cFmt.setAnchor(True)
|
||||
cFmt.setAnchorNames([f"tag_{one}".lower()])
|
||||
cursor.insertText(one, cFmt)
|
||||
if two:
|
||||
cursor.insertText(" | ", self._cText)
|
||||
cursor.insertText(two, self._cOptional)
|
||||
else:
|
||||
for n, bit in enumerate(bits[1:], 2):
|
||||
cFmt = QTextCharFormat(self._cTag)
|
||||
cFmt.setFontUnderline(True)
|
||||
cFmt.setAnchor(True)
|
||||
cFmt.setAnchorHref(f"#tag_{bit}".lower())
|
||||
cursor.insertText(bit, cFmt)
|
||||
if n < num:
|
||||
cursor.insertText(", ", self._cText)
|
||||
return
|
||||
|
||||
def _genHeadStyle(self, hType: int, nHead: int, rFmt: QTextBlockFormat) -> T_TextStyle:
|
||||
"""Generate a heading style set."""
|
||||
mTop, mBottom = self._mHead.get(hType, (0.0, 0.0))
|
||||
|
||||
bFmt = QTextBlockFormat(rFmt)
|
||||
bFmt.setTopMargin(mTop)
|
||||
bFmt.setBottomMargin(mBottom)
|
||||
|
||||
cFmt = QTextCharFormat(self._cText if hType == self.T_TITLE else self._cHead)
|
||||
cFmt.setFontWeight(self._bold)
|
||||
cFmt.setFontPointSize(self._sHead.get(hType, 1.0))
|
||||
if nHead >= 0:
|
||||
cFmt.setAnchorNames([f"{self._handle}:T{nHead:04d}"])
|
||||
cFmt.setAnchor(True)
|
||||
|
||||
return bFmt, cFmt
|
||||
@@ -3,10 +3,11 @@ novelWriter – Project Tree Class
|
||||
================================
|
||||
|
||||
File History:
|
||||
Created: 2020-05-07 [0.4.5] NWTree
|
||||
Created: 2020-05-07 [0.4.5] NWTree
|
||||
Rewritten: 2024-11-16 [2.6b2] NWTree
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2020 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -30,10 +31,13 @@ from collections.abc import Iterable, Iterator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Literal, overload
|
||||
|
||||
from novelwriter.common import isHandle
|
||||
from novelwriter.constants import nwFiles
|
||||
from PyQt5.QtCore import QModelIndex
|
||||
|
||||
from novelwriter import SHARED
|
||||
from novelwriter.constants import nwFiles, nwLabels, trConst
|
||||
from novelwriter.core.item import NWItem
|
||||
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
|
||||
from novelwriter.core.itemmodel import ProjectModel, ProjectNode
|
||||
from novelwriter.enum import nwChange, nwItemClass, nwItemLayout, nwItemType
|
||||
from novelwriter.error import logException
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -41,7 +45,7 @@ if TYPE_CHECKING: # pragma: no cover
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_DEPTH = 1000 # Cap of tree traversing for loops (recursion limit)
|
||||
MAX_DEPTH = 999 # Cap of tree traversing for loops (recursion limit)
|
||||
|
||||
|
||||
class NWTree:
|
||||
@@ -51,30 +55,51 @@ class NWTree:
|
||||
This class holds all the project items of the project as instances
|
||||
of NWItem.
|
||||
|
||||
For historical reasons, the order of the items is saved in a
|
||||
separate list from the items themselves, which are stored in a
|
||||
dictionary. This is somewhat redundant with the newer versions of
|
||||
Python, but is still practical as it's easier to update the item
|
||||
order as a list.
|
||||
|
||||
Each item has a handle, which is a random hex string of length 13.
|
||||
The handle is the name of the item everywhere in novelWriter, and is
|
||||
also used for file names.
|
||||
"""
|
||||
|
||||
__slots__ = ("_project", "_tree", "_order", "_roots", "_trash", "_changed")
|
||||
__slots__ = ("_project", "_model", "_items", "_nodes", "_trash")
|
||||
|
||||
def __init__(self, project: NWProject) -> None:
|
||||
|
||||
self._project = project
|
||||
self._model = ProjectModel(self)
|
||||
self._items: dict[str, NWItem] = {}
|
||||
self._nodes: dict[str, ProjectNode] = {}
|
||||
self._trash = None
|
||||
logger.debug("Ready: NWTree")
|
||||
return
|
||||
|
||||
self._tree: dict[str, NWItem] = {} # Holds all the items of the project
|
||||
self._order: list[str] = [] # The order of the tree items in the tree view
|
||||
self._roots: dict[str, NWItem] = {} # The root items of the tree
|
||||
def __del__(self) -> None: # pragma: no cover
|
||||
logger.debug("Delete: NWTree")
|
||||
return
|
||||
|
||||
self._trash = None # The handle of the trash root folder
|
||||
self._changed = False # True if tree structure has changed
|
||||
def __len__(self) -> int:
|
||||
"""The number of items in the project."""
|
||||
return len(self._items)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""True if there are any items in the project."""
|
||||
return bool(self._items)
|
||||
|
||||
def __getitem__(self, tHandle: str | None) -> NWItem | None:
|
||||
"""Return a project item based on its handle. Returns None if
|
||||
the handle doesn't exist in the project.
|
||||
"""
|
||||
if tHandle and tHandle in self._items:
|
||||
return self._items[tHandle]
|
||||
logger.error("No tree item with handle '%s'", str(tHandle))
|
||||
return None
|
||||
|
||||
def __contains__(self, tHandle: str) -> bool:
|
||||
"""Checks if a handle exists in the tree."""
|
||||
return tHandle in self._items
|
||||
|
||||
def __iter__(self) -> Iterator[NWItem]:
|
||||
"""Iterate through project items."""
|
||||
for node in self._model.root.allChildren():
|
||||
yield node.item
|
||||
return
|
||||
|
||||
##
|
||||
@@ -82,9 +107,19 @@ class NWTree:
|
||||
##
|
||||
|
||||
@property
|
||||
def trashRoot(self) -> str | None:
|
||||
"""Return the handle of the trash folder, or None."""
|
||||
return self._trash
|
||||
def trash(self) -> ProjectNode | None:
|
||||
"""Return trash node, if it exists."""
|
||||
if self._trash:
|
||||
return self._trash
|
||||
return self._getTrashNode()
|
||||
|
||||
@property
|
||||
def model(self) -> ProjectModel:
|
||||
return self._model
|
||||
|
||||
@property
|
||||
def nodes(self) -> dict[str, ProjectNode]:
|
||||
return self._nodes
|
||||
|
||||
##
|
||||
# Class Methods
|
||||
@@ -92,83 +127,91 @@ class NWTree:
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the item tree entirely."""
|
||||
self._tree = {}
|
||||
self._order = []
|
||||
self._roots = {}
|
||||
self._trash = None
|
||||
self._changed = False
|
||||
oldModel = self._model
|
||||
oldModel.clear()
|
||||
self._model = ProjectModel(self)
|
||||
self._items.clear()
|
||||
self._nodes.clear()
|
||||
self._trash = None
|
||||
oldModel.deleteLater()
|
||||
del oldModel
|
||||
return
|
||||
|
||||
def handles(self) -> list[str]:
|
||||
"""Returns a copy of the list of all the active handles."""
|
||||
return self._order.copy()
|
||||
def add(self, item: NWItem, pos: int = -1) -> bool:
|
||||
"""Add a project item into the project tree."""
|
||||
if pHandle := item.itemParent:
|
||||
if parent := self._nodes.get(pHandle):
|
||||
node = ProjectNode(item)
|
||||
index = self._model.indexFromNode(parent)
|
||||
self._model.insertChild(node, index, pos)
|
||||
self._nodes[item.itemHandle] = node
|
||||
self._items[item.itemHandle] = item
|
||||
self._itemChange(item, nwChange.CREATE)
|
||||
else:
|
||||
logger.error("Could not locate parent of '%s'", item.itemHandle)
|
||||
return False
|
||||
elif item.isRootType():
|
||||
node = ProjectNode(item)
|
||||
self._model.insertChild(node, QModelIndex(), pos)
|
||||
self._nodes[item.itemHandle] = node
|
||||
self._items[item.itemHandle] = item
|
||||
self._itemChange(item, nwChange.CREATE)
|
||||
else:
|
||||
logger.error("Invalid project item '%s'", item.itemHandle)
|
||||
return False
|
||||
return True
|
||||
|
||||
def remove(self, tHandle: str) -> bool:
|
||||
"""Remove an item from the project tree."""
|
||||
if (node := self._nodes.get(tHandle)) and tHandle in self._items:
|
||||
index = self._model.indexFromNode(node)
|
||||
if index.isValid() and self._model.removeChild(index.parent(), index.row()):
|
||||
self._itemChange(node.item, nwChange.DELETE)
|
||||
del self._nodes[tHandle]
|
||||
del self._items[tHandle]
|
||||
return True
|
||||
return False
|
||||
|
||||
@overload # pragma: no cover
|
||||
def create(self, label: str, parent: None, itemType: Literal[nwItemType.ROOT],
|
||||
itemClass: nwItemClass) -> str:
|
||||
def create(
|
||||
self, label: str, parent: None, itemType: Literal[nwItemType.ROOT],
|
||||
itemClass: nwItemClass, pos: int = -1
|
||||
) -> str:
|
||||
pass
|
||||
|
||||
@overload # pragma: no cover
|
||||
def create(self, label: str, parent: str | None, itemType: nwItemType,
|
||||
itemClass: nwItemClass = nwItemClass.NO_CLASS) -> str | None:
|
||||
def create(
|
||||
self, label: str, parent: str | None, itemType: nwItemType,
|
||||
itemClass: nwItemClass = nwItemClass.NO_CLASS, pos: int = -1
|
||||
) -> str | None:
|
||||
pass
|
||||
|
||||
def create(self, label, parent, itemType, itemClass=nwItemClass.NO_CLASS):
|
||||
def create(
|
||||
self, label: str, parent: str | None, itemType: nwItemType,
|
||||
itemClass: nwItemClass = nwItemClass.NO_CLASS, pos: int = -1,
|
||||
) -> str | None:
|
||||
"""Create a new item in the project tree, and return its handle.
|
||||
If the item cannot be added to the project because of an invalid
|
||||
parent, None is returned. For root elements, this cannot occur.
|
||||
"""
|
||||
parent = None if itemType == nwItemType.ROOT else parent
|
||||
if parent is None or parent in self._order:
|
||||
if parent is None or parent in self._nodes:
|
||||
tHandle = self._makeHandle()
|
||||
newItem = NWItem(self._project, tHandle)
|
||||
newItem.setName(label)
|
||||
newItem.setParent(parent)
|
||||
newItem.setType(itemType)
|
||||
newItem.setClass(itemClass)
|
||||
self.append(newItem)
|
||||
self.updateItemData(tHandle)
|
||||
return tHandle
|
||||
nwItem = NWItem(self._project, tHandle)
|
||||
nwItem.setName(label)
|
||||
nwItem.setParent(parent)
|
||||
nwItem.setType(itemType)
|
||||
nwItem.setClass(itemClass)
|
||||
if self.add(nwItem, pos):
|
||||
return tHandle
|
||||
return None
|
||||
|
||||
def append(self, nwItem: NWItem) -> bool:
|
||||
"""Add a new item to the end of the tree."""
|
||||
tHandle = nwItem.itemHandle
|
||||
pHandle = nwItem.itemParent
|
||||
|
||||
if not isHandle(tHandle):
|
||||
logger.warning("Invalid item handle '%s' detected, skipping", tHandle)
|
||||
return False
|
||||
|
||||
if tHandle in self._tree:
|
||||
logger.warning("Duplicate handle '%s' detected, skipping", tHandle)
|
||||
return False
|
||||
|
||||
logger.debug("Adding item '%s' with parent '%s'", str(tHandle), str(pHandle))
|
||||
|
||||
if nwItem.isRootType():
|
||||
logger.debug("Item '%s' is a root item", str(tHandle))
|
||||
self._roots[tHandle] = nwItem
|
||||
if nwItem.itemClass == nwItemClass.TRASH:
|
||||
if self._trash is None:
|
||||
logger.debug("Item '%s' is the trash folder", str(tHandle))
|
||||
self._trash = tHandle
|
||||
else:
|
||||
logger.error("Only one trash folder allowed")
|
||||
return False
|
||||
|
||||
self._tree[tHandle] = nwItem
|
||||
self._order.append(tHandle)
|
||||
self._setTreeChanged(True)
|
||||
|
||||
return True
|
||||
|
||||
def duplicate(self, sHandle: str) -> NWItem | None:
|
||||
def duplicate(self, sHandle: str, pHandle: str | None, putAfter: bool) -> NWItem | None:
|
||||
"""Duplicate an item and set a new handle."""
|
||||
sItem = self.__getitem__(sHandle)
|
||||
if isinstance(sItem, NWItem):
|
||||
nItem = NWItem.duplicate(sItem, self._makeHandle())
|
||||
if self.append(nItem):
|
||||
if sNode := self._nodes.get(sHandle):
|
||||
nItem = NWItem.duplicate(sNode.item, self._makeHandle())
|
||||
nItem.setParent(pHandle)
|
||||
if self.add(nItem, (sNode.row() + 1) if putAfter else -1):
|
||||
logger.info("Duplicated item '%s' -> '%s'", sHandle, nItem.itemHandle)
|
||||
return nItem
|
||||
return None
|
||||
@@ -177,23 +220,62 @@ class NWTree:
|
||||
"""Pack the content of the tree into a list of dictionaries of
|
||||
items. In the order defined by the _treeOrder list.
|
||||
"""
|
||||
tree = []
|
||||
for tHandle in self._order:
|
||||
tItem = self.__getitem__(tHandle)
|
||||
if tItem:
|
||||
tree.append(tItem.pack())
|
||||
return tree
|
||||
nodes = self._model.root.allChildren()
|
||||
if len(nodes) != len(self._nodes):
|
||||
logger.warning(
|
||||
"Model tree is inconsitent with nodes map, %d != %d",
|
||||
len(nodes), len(self._nodes)
|
||||
)
|
||||
return [node.item.pack() for node in nodes]
|
||||
|
||||
def unpack(self, data: list[dict]) -> None:
|
||||
"""Iterate through all items of a list and add them to the
|
||||
project tree.
|
||||
"""
|
||||
self.clear()
|
||||
items: dict[str, NWItem] = self._items.copy()
|
||||
for item in data:
|
||||
nwItem = NWItem(self._project, "") # Handle is set by unpack()
|
||||
nwItem = NWItem(self._project, "")
|
||||
if nwItem.unpack(item):
|
||||
self.append(nwItem)
|
||||
nwItem.saveInitialCount()
|
||||
items[nwItem.itemHandle] = nwItem
|
||||
|
||||
later = items
|
||||
self._model.beginInsertRows(self._model.index(0, 0), 0, 0)
|
||||
for _ in range(MAX_DEPTH):
|
||||
later = self._addItems(later)
|
||||
if len(later) == 0:
|
||||
break
|
||||
else:
|
||||
logger.error("Not all items could be added to project tree")
|
||||
|
||||
self._trash = self._getTrashNode()
|
||||
self._model.endInsertRows()
|
||||
self._model.layoutChanged.emit()
|
||||
|
||||
return
|
||||
|
||||
def refreshItems(self, items: list[str]) -> None:
|
||||
"""Refresh these items on the GUI. If they are an ordered range,
|
||||
also set the isRange flag to True.
|
||||
"""
|
||||
for tHandle in items:
|
||||
if node := self._nodes.get(tHandle):
|
||||
node.refresh()
|
||||
node.updateCount()
|
||||
indexS = self._model.indexFromNode(node, 0)
|
||||
indexE = self._model.indexFromNode(node, 3)
|
||||
self._model.dataChanged.emit(indexS, indexE)
|
||||
self._itemChange(node.item, nwChange.UPDATE)
|
||||
return
|
||||
|
||||
def refreshAllItems(self) -> None:
|
||||
"""Refresh all items in the tree."""
|
||||
for node in reversed(self._model.root.allChildren()):
|
||||
node.refresh()
|
||||
node.updateCount(propagate=False)
|
||||
self._model.root.refresh()
|
||||
self._model.root.updateCount(propagate=False)
|
||||
self._model.layoutChanged.emit()
|
||||
return
|
||||
|
||||
def checkConsistency(self, prefix: str) -> tuple[int, int]:
|
||||
@@ -205,29 +287,21 @@ class NWTree:
|
||||
mark recovered files.
|
||||
"""
|
||||
storage = self._project.storage
|
||||
files = set(storage.scanContent())
|
||||
for tHandle in self._order:
|
||||
if self.updateItemData(tHandle):
|
||||
logger.debug("Checking item '%s' ... OK", tHandle)
|
||||
files.discard(tHandle) # Remove it from the record
|
||||
else:
|
||||
logger.error("Checking item '%s' ... ERROR", tHandle)
|
||||
self.__delitem__(tHandle) # The file will be re-added as orphaned
|
||||
|
||||
orphans = len(files)
|
||||
remains = set(storage.scanContent()).difference(set(self._nodes.keys()))
|
||||
orphans = len(remains)
|
||||
if orphans == 0:
|
||||
logger.info("Checked project files: OK")
|
||||
return 0, 0
|
||||
|
||||
logger.warning("Found %d file(s) not tracked in project", orphans)
|
||||
recovered = 0
|
||||
for cHandle in files:
|
||||
for cHandle in remains:
|
||||
aDoc = storage.getDocument(cHandle)
|
||||
aDoc.readDocument(isOrphan=True)
|
||||
oName, oParent, oClass, oLayout = aDoc.getMeta()
|
||||
|
||||
oName = oName or cHandle
|
||||
oParent = oParent if oParent in self._order else None
|
||||
oParent = oParent if oParent in self._nodes else None
|
||||
oClass = oClass or nwItemClass.NOVEL
|
||||
oLayout = oLayout or nwItemLayout.NOTE
|
||||
|
||||
@@ -248,8 +322,7 @@ class NWTree:
|
||||
newItem.setType(nwItemType.FILE)
|
||||
newItem.setClass(oClass)
|
||||
newItem.setLayout(oLayout)
|
||||
if self.append(newItem):
|
||||
self.updateItemData(cHandle)
|
||||
if self.add(newItem):
|
||||
recovered += 1
|
||||
|
||||
return orphans, recovered
|
||||
@@ -263,38 +336,33 @@ class NWTree:
|
||||
if not (isinstance(contentPath, Path) and isinstance(runtimePath, Path)):
|
||||
return False
|
||||
|
||||
tocList = []
|
||||
tocLen = 0
|
||||
for tHandle in self._order:
|
||||
tItem = self.__getitem__(tHandle)
|
||||
if tItem is None:
|
||||
continue
|
||||
|
||||
tFile = tHandle+".nwd"
|
||||
if (contentPath / tFile).is_file():
|
||||
entries = []
|
||||
maxLen = 0
|
||||
for node in self._model.root.allChildren():
|
||||
item = node.item
|
||||
file = f"{item.itemHandle}.nwd"
|
||||
if (contentPath / file).is_file():
|
||||
tocLine = "{0:<25s} {1:<9s} {2:<8s} {3:s}".format(
|
||||
str(Path("content") / tFile),
|
||||
tItem.itemClass.name,
|
||||
tItem.itemLayout.name,
|
||||
tItem.itemName,
|
||||
f"content/{file}",
|
||||
item.itemClass.name,
|
||||
item.itemLayout.name,
|
||||
item.itemName,
|
||||
)
|
||||
tocList.append(tocLine)
|
||||
tocLen = max(tocLen, len(tocLine))
|
||||
entries.append(tocLine)
|
||||
maxLen = max(maxLen, len(tocLine))
|
||||
|
||||
try:
|
||||
# Dump the text
|
||||
tocText = runtimePath / nwFiles.TOC_TXT
|
||||
with open(tocText, mode="w", encoding="utf-8") as outFile:
|
||||
outFile.write("\n")
|
||||
outFile.write("Table of Contents\n")
|
||||
outFile.write("=================\n")
|
||||
outFile.write("\n")
|
||||
outFile.write("{0:<25s} {1:<9s} {2:<8s} {3:s}\n".format(
|
||||
with open(runtimePath / nwFiles.TOC_TXT, mode="w", encoding="utf-8") as toc:
|
||||
toc.write("\n")
|
||||
toc.write("Table of Contents\n")
|
||||
toc.write("=================\n")
|
||||
toc.write("\n")
|
||||
toc.write("{0:<25s} {1:<9s} {2:<8s} {3:s}\n".format(
|
||||
"File Name", "Class", "Layout", "Document Label"
|
||||
))
|
||||
outFile.write("-"*max(tocLen, 62) + "\n")
|
||||
outFile.write("\n".join(tocList))
|
||||
outFile.write("\n")
|
||||
toc.write("-"*max(maxLen, 62) + "\n")
|
||||
toc.write("\n".join(entries))
|
||||
toc.write("\n")
|
||||
|
||||
except Exception:
|
||||
logger.error("Could not write ToC file")
|
||||
@@ -307,74 +375,46 @@ class NWTree:
|
||||
"""Loop over all entries and add up the word counts."""
|
||||
noteWords = 0
|
||||
novelWords = 0
|
||||
for tHandle in self._order:
|
||||
tItem = self.__getitem__(tHandle)
|
||||
if tItem is None:
|
||||
continue
|
||||
if tItem.itemLayout == nwItemLayout.NO_LAYOUT:
|
||||
pass
|
||||
elif tItem.itemLayout == nwItemLayout.NOTE:
|
||||
noteWords += tItem.wordCount
|
||||
else:
|
||||
novelWords += tItem.wordCount
|
||||
for item in self._items.values():
|
||||
if item.itemLayout == nwItemLayout.NOTE:
|
||||
noteWords += item.wordCount
|
||||
elif item.itemLayout == nwItemLayout.DOCUMENT:
|
||||
novelWords += item.wordCount
|
||||
return novelWords, noteWords
|
||||
|
||||
##
|
||||
# Tree Item Methods
|
||||
##
|
||||
|
||||
def updateItemData(self, tHandle: str) -> bool:
|
||||
"""Update the root item handle of a given item. Returns True if
|
||||
a root was found and data updated, otherwise False.
|
||||
"""
|
||||
tItem = self.__getitem__(tHandle)
|
||||
if tItem is None:
|
||||
return False
|
||||
|
||||
iItem = tItem
|
||||
for _ in range(MAX_DEPTH):
|
||||
if iItem.itemParent is None:
|
||||
tItem.setRoot(iItem.itemHandle)
|
||||
tItem.setClassDefaults(iItem.itemClass)
|
||||
return True
|
||||
else:
|
||||
iItem = self.__getitem__(iItem.itemParent)
|
||||
if iItem is None:
|
||||
return False
|
||||
else:
|
||||
raise RecursionError("Critical internal error")
|
||||
|
||||
def checkType(self, tHandle: str, itemType: nwItemType) -> bool:
|
||||
"""Check if item exists and is of the specified item type."""
|
||||
tItem = self.__getitem__(tHandle)
|
||||
if not tItem:
|
||||
return False
|
||||
return tItem.itemType == itemType
|
||||
if tItem := self.__getitem__(tHandle):
|
||||
return tItem.itemType == itemType
|
||||
return False
|
||||
|
||||
def getItemPath(self, tHandle: str, asName: bool = False) -> list[str]:
|
||||
def itemPath(self, tHandle: str, asName: bool = False) -> list[str]:
|
||||
"""Iterate upwards in the tree until we find the item with
|
||||
parent None, the root item, and return the list of handles, or
|
||||
alternatively item names. We do this with a for loop with a
|
||||
maximum depth to make infinite loops impossible.
|
||||
"""
|
||||
tTree = []
|
||||
tItem = self.__getitem__(tHandle)
|
||||
if tItem is not None:
|
||||
tTree.append(tItem.itemName if asName else tHandle)
|
||||
path = []
|
||||
if node := self._nodes.get(tHandle):
|
||||
for _ in range(MAX_DEPTH):
|
||||
if tItem.itemParent is None:
|
||||
return tTree
|
||||
if parent := node.parent():
|
||||
path.append(node.item.itemName if asName else tHandle)
|
||||
node = parent
|
||||
else:
|
||||
tHandle = tItem.itemParent
|
||||
tItem = self.__getitem__(tHandle)
|
||||
if tItem is None:
|
||||
return tTree
|
||||
else:
|
||||
tTree.append(tItem.itemName if asName else tHandle)
|
||||
return path
|
||||
else:
|
||||
raise RecursionError("Critical internal error")
|
||||
logger.error("Max project tree depth reached")
|
||||
return path
|
||||
|
||||
return tTree
|
||||
def subTree(self, tHandle: str) -> list[str]:
|
||||
"""Get the subtree from a given handle."""
|
||||
if node := self._nodes.get(tHandle):
|
||||
return [child.item.itemHandle for child in node.allChildren()]
|
||||
return []
|
||||
|
||||
##
|
||||
# Tree Root Methods
|
||||
@@ -383,139 +423,80 @@ class NWTree:
|
||||
def rootClasses(self) -> set[nwItemClass]:
|
||||
"""Return a set of all root classes in use by the project."""
|
||||
rootClasses = set()
|
||||
for nwItem in self._roots.values():
|
||||
rootClasses.add(nwItem.itemClass)
|
||||
for node in self._model.root.children:
|
||||
rootClasses.add(node.item.itemClass)
|
||||
return rootClasses
|
||||
|
||||
def iterRoots(self, itemClass: nwItemClass | None) -> Iterable[tuple[str, NWItem]]:
|
||||
"""Iterate over all root items of a given class in order."""
|
||||
for tHandle in self._order:
|
||||
nwItem = self.__getitem__(tHandle)
|
||||
if isinstance(nwItem, NWItem) and nwItem.isRootType():
|
||||
if itemClass is None or nwItem.itemClass == itemClass:
|
||||
yield tHandle, nwItem
|
||||
for node in self._model.root.children:
|
||||
if node.item.isRootType():
|
||||
if itemClass is None or node.item.itemClass == itemClass:
|
||||
yield node.item.itemHandle, node.item
|
||||
return
|
||||
|
||||
def isTrash(self, tHandle: str) -> bool:
|
||||
"""Check if an item is in or is the trash folder."""
|
||||
tItem = self.__getitem__(tHandle)
|
||||
if tItem is None:
|
||||
return True
|
||||
if tItem.itemClass == nwItemClass.TRASH:
|
||||
return True
|
||||
if self._trash is not None:
|
||||
if tHandle == self._trash:
|
||||
return True
|
||||
elif tItem.itemParent == self._trash:
|
||||
return True
|
||||
elif tItem.itemRoot == self._trash:
|
||||
return True
|
||||
return False
|
||||
|
||||
def findRoot(self, itemClass: nwItemClass | None) -> str | None:
|
||||
"""Find the first root item for a given class."""
|
||||
for aRoot in self._roots:
|
||||
tItem = self.__getitem__(aRoot)
|
||||
if tItem is None:
|
||||
continue
|
||||
if itemClass == tItem.itemClass:
|
||||
return tItem.itemHandle
|
||||
for node in self._model.root.children:
|
||||
if node.item.itemClass == itemClass:
|
||||
return node.item.itemHandle
|
||||
return None
|
||||
|
||||
##
|
||||
# Setters
|
||||
##
|
||||
|
||||
def setOrder(self, newOrder: list[str]) -> None:
|
||||
"""Reorders the tree based on a list of items."""
|
||||
tmpOrder = [tHandle for tHandle in newOrder if tHandle in self._tree]
|
||||
if not (len(tmpOrder) == len(newOrder) == len(self._order)):
|
||||
# Something is wrong, so let's debug it
|
||||
for tHandle in newOrder:
|
||||
if tHandle not in self._tree:
|
||||
logger.error("Handle '%s' in new tree order is not in old order", tHandle)
|
||||
for tHandle in self._order:
|
||||
if tHandle not in tmpOrder:
|
||||
logger.warning("Handle '%s' in old tree order is not in new order", tHandle)
|
||||
|
||||
# Save the temp list
|
||||
self._order = tmpOrder
|
||||
self._setTreeChanged(True)
|
||||
logger.debug("Project tree order updated")
|
||||
|
||||
return
|
||||
|
||||
##
|
||||
# Special Methods
|
||||
##
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""The number of items in the project."""
|
||||
return len(self._order)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""True if there are any items in the project."""
|
||||
return bool(self._order)
|
||||
|
||||
def __getitem__(self, tHandle: str | None) -> NWItem | None:
|
||||
"""Return a project item based on its handle. Returns None if
|
||||
the handle doesn't exist in the project.
|
||||
"""
|
||||
if tHandle and tHandle in self._tree:
|
||||
return self._tree[tHandle]
|
||||
logger.error("No tree item with handle '%s'", str(tHandle))
|
||||
return None
|
||||
|
||||
def __delitem__(self, tHandle: str) -> None:
|
||||
"""Remove an item from the internal lists and dictionaries."""
|
||||
if tHandle in self._order and tHandle in self._tree:
|
||||
self._order.remove(tHandle)
|
||||
del self._tree[tHandle]
|
||||
else:
|
||||
logger.warning("Failed to delete item '%s': item not found", tHandle)
|
||||
return
|
||||
|
||||
if tHandle in self._roots:
|
||||
del self._roots[tHandle]
|
||||
if tHandle == self._trash:
|
||||
self._trash = None
|
||||
|
||||
self._setTreeChanged(True)
|
||||
|
||||
return
|
||||
|
||||
def __contains__(self, tHandle: str) -> bool:
|
||||
"""Checks if a handle exists in the tree."""
|
||||
return tHandle in self._order
|
||||
|
||||
def __iter__(self) -> Iterator[NWItem]:
|
||||
"""Iterate through project items."""
|
||||
for tHandle in self._order:
|
||||
tItem = self._tree.get(tHandle)
|
||||
if isinstance(tItem, NWItem):
|
||||
yield tItem
|
||||
return
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _setTreeChanged(self, state: bool) -> None:
|
||||
"""Set the changed flag to state, and if being set to True,
|
||||
propagate that state change to the parent NWProject class.
|
||||
"""
|
||||
self._changed = state
|
||||
if state:
|
||||
self._project.setProjectChanged(True)
|
||||
def _itemChange(self, item: NWItem, change: nwChange) -> None:
|
||||
"""Signal item change and notify project."""
|
||||
tHandle = item.itemHandle
|
||||
logger.debug("Item change: %s -> %s", tHandle, change.name)
|
||||
self._project.setProjectChanged(True)
|
||||
SHARED.emitProjectItemChanged(self._project, tHandle, change)
|
||||
if item.isRootType():
|
||||
SHARED.emitRootFolderChanged(self._project, tHandle, change)
|
||||
return
|
||||
|
||||
def _getTrashNode(self) -> ProjectNode | None:
|
||||
"""Get the trash node. If it doesn't exist, create it."""
|
||||
for node in self._model.root.children:
|
||||
if node.item.itemClass == nwItemClass.TRASH:
|
||||
return node
|
||||
label = trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH])
|
||||
if handle := self.create(label, None, nwItemType.ROOT, nwItemClass.TRASH):
|
||||
return self._nodes.get(handle)
|
||||
return None
|
||||
|
||||
def _addItems(self, items: dict[str, NWItem]) -> dict[str, NWItem]:
|
||||
"""Add a dictionary of items to the project tree. Returns a new
|
||||
dictionary of items that could not be added yet, but can be.
|
||||
"""
|
||||
remains: dict[str, NWItem] = {}
|
||||
for handle, item in items.items():
|
||||
if pHandle := item.itemParent:
|
||||
if parent := self._nodes.get(pHandle):
|
||||
node = ProjectNode(item)
|
||||
parent.addChild(node)
|
||||
parent.updateCount()
|
||||
self._items[handle] = item
|
||||
self._nodes[handle] = node
|
||||
elif pHandle in items:
|
||||
remains[handle] = item
|
||||
logger.warning("Item '%s' found before its parent", handle)
|
||||
elif item.isRootType():
|
||||
node = ProjectNode(item)
|
||||
self._model.root.addChild(node)
|
||||
self._model.root.updateCount()
|
||||
self._items[handle] = item
|
||||
self._nodes[handle] = node
|
||||
return remains
|
||||
|
||||
def _makeHandle(self) -> str:
|
||||
"""Generate a unique item handle. In the event that the key
|
||||
already exists, generate a new one.
|
||||
"""
|
||||
logger.debug("Generating new handle")
|
||||
handle = f"{random.getrandbits(52):013x}"
|
||||
if handle in self._tree:
|
||||
if handle in self._items:
|
||||
logger.warning("Duplicate handle encountered! Retrying ...")
|
||||
handle = self._makeHandle()
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ File History:
|
||||
Created: 2020-05-21 [0.5.2] GuiAbout
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2020 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
|
||||
@@ -7,7 +7,7 @@ Created: 2020-01-23 [0.4.3] GuiDocMerge
|
||||
Rewritten: 2022-10-06 [2.0rc1] GuiDocMerge
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2020 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -162,23 +162,13 @@ class GuiDocMerge(NDialog):
|
||||
self._data = {}
|
||||
self._data["sHandle"] = sHandle
|
||||
self._data["origItems"] = itemList
|
||||
|
||||
self.listBox.clear()
|
||||
for tHandle in itemList:
|
||||
nwItem = SHARED.project.tree[tHandle]
|
||||
if nwItem is None or not nwItem.isFileType():
|
||||
continue
|
||||
|
||||
itemIcon = SHARED.theme.getItemIcon(
|
||||
nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, nwItem.mainHeading
|
||||
)
|
||||
|
||||
newItem = QListWidgetItem()
|
||||
newItem.setIcon(itemIcon)
|
||||
newItem.setText(nwItem.itemName)
|
||||
newItem.setData(self.D_HANDLE, tHandle)
|
||||
newItem.setCheckState(Qt.CheckState.Checked)
|
||||
|
||||
self.listBox.addItem(newItem)
|
||||
|
||||
if (nwItem := SHARED.project.tree[tHandle]) and nwItem.isFileType():
|
||||
item = QListWidgetItem()
|
||||
item.setIcon(nwItem.getMainIcon())
|
||||
item.setText(nwItem.itemName)
|
||||
item.setData(self.D_HANDLE, tHandle)
|
||||
item.setCheckState(Qt.CheckState.Checked)
|
||||
self.listBox.addItem(item)
|
||||
return
|
||||
|
||||
@@ -7,7 +7,7 @@ Created: 2020-02-01 [0.4.3] GuiDocSplit
|
||||
Rewritten: 2022-10-12 [2.0rc1] GuiDocSplit
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
Copyright (C) 2020 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
|
||||